@dogfood-lab/findings 1.3.2 → 1.5.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.
package/README.md CHANGED
@@ -58,8 +58,62 @@ if (!result.ok) {
58
58
  | Derive | `derive/derive-findings.js` | New finding files under `findings/`; deduplication via `derive/dedupe.js` |
59
59
  | Review | `review/review-engine.js` | Status transitions + `event-log.jsonl` audit trail |
60
60
  | Synthesize | `synthesis/pattern-derivation.js`, `synthesis/recommendation-derivation.js`, `synthesis/doctrine-derivation.js` | Pattern, recommendation, doctrine artifacts |
61
+ | Review (artifacts) | `review/review-artifacts.js` | Promotes a synthesis-artifact `candidate` → `accepted` so it reaches the advise surface |
61
62
  | Advise | `advise/advice-bundle.js`, `advise/query.js` | Advisory bundles for downstream consumers (e.g., `@dogfood-lab/dogfood-swarm`) |
62
63
 
64
+ ### Closing the intelligence loop
65
+
66
+ Synthesis writes patterns, recommendations, and doctrine with `status: 'candidate'`,
67
+ but the advise layer (`queryPatterns` / `queryRecommendations` / `queryDoctrine`)
68
+ surfaces only `accepted` artifacts. The artifact review verbs are what promote a
69
+ candidate so the intelligence layer actually reaches future projects:
70
+
71
+ ```bash
72
+ # Promote a derived pattern into the advise surface (accept = the loop closes)
73
+ npx @dogfood-lab/findings patterns accept dpat-xxxx --actor mike --reason "sound recurrence"
74
+
75
+ # Same for recommendations and doctrine
76
+ npx @dogfood-lab/findings recommendations accept drec-xxxx --actor mike
77
+ npx @dogfood-lab/findings doctrine accept ddoc-xxxx --actor mike
78
+
79
+ # Retire an accepted pattern when source truth changes (patterns only)
80
+ npx @dogfood-lab/findings patterns invalidate dpat-xxxx --actor mike --reason "source changed"
81
+
82
+ # See what is awaiting review
83
+ npx @dogfood-lab/findings patterns queue
84
+ ```
85
+
86
+ The artifact review law reuses the finding status law (`review/transitions.js`).
87
+ Patterns carry a literal `invalidated` status; recommendations and doctrine do
88
+ not, so `invalidate` is supported for patterns only. `review` / `reopen` target
89
+ the intermediate `reviewed` state, which the artifact schemas do not allow — they
90
+ are refused honestly rather than writing a schema-invalid artifact.
91
+
92
+ **Re-derivation safety:** re-running `<type> derive --write` will not overwrite an
93
+ artifact you have already promoted. A freshly-derived `candidate` that collides
94
+ with an accepted / rejected / invalidated id is preserved, not clobbered
95
+ (`synthesis/dedupe-artifacts.js`, mirroring `derive/dedupe.js`).
96
+
97
+ ### Applying a recommendation back into a policy
98
+
99
+ An **accepted** recommendation whose action is a structured `add_scenario` /
100
+ `add_check` can be applied directly into a named repo policy:
101
+
102
+ ```bash
103
+ # Preview the change (default — writes nothing)
104
+ npx @dogfood-lab/findings recommendations apply drec-xxxx --policy mcp-tool-shop-org/widget
105
+
106
+ # Apply the structured intent: add the target scenario id to the policy's
107
+ # required_scenarios for the recommendation's surface, recording provenance
108
+ npx @dogfood-lab/findings recommendations apply drec-xxxx --write --policy mcp-tool-shop-org/widget --actor mike
109
+ ```
110
+
111
+ This is honest partial automation. Only the structured `target` id is applied;
112
+ the free-text `action.details` is recorded as provenance and **never** injected as
113
+ policy logic. Free-text-only action types (`set_policy`, `set_evidence`, …) and
114
+ ambiguous targets (no named policy, multiple surfaces) refuse `--write` with a
115
+ structured `{ code, message, hint }` telling the operator to apply manually.
116
+
63
117
  ## Finding shape
64
118
 
65
119
  ```yaml
package/cli.js CHANGED
@@ -41,6 +41,12 @@ import {
41
41
  getReviewQueue,
42
42
  getEventsForFinding
43
43
  } from './review/index.js';
44
+ import {
45
+ reviewArtifact,
46
+ findArtifactById,
47
+ getArtifactReviewQueue
48
+ } from './review/review-artifacts.js';
49
+ import { applyRecommendation } from './synthesis/apply-recommendation.js';
44
50
  import {
45
51
  derivePatterns,
46
52
  deriveRecommendations,
@@ -56,7 +62,11 @@ import {
56
62
  writeDoctrines,
57
63
  loadPatterns,
58
64
  loadRecommendations,
59
- loadDoctrines
65
+ loadDoctrines,
66
+ loadPatternsWithSkips,
67
+ loadRecommendationsWithSkips,
68
+ loadDoctrinesWithSkips,
69
+ dedupeArtifactsAgainstExisting
60
70
  } from './synthesis/index.js';
61
71
  import {
62
72
  generateAdviceBundle,
@@ -165,6 +175,64 @@ function formatFindingDetail(f, rootDir) {
165
175
  return lines.filter(l => l !== null).join('\n');
166
176
  }
167
177
 
178
+ /**
179
+ * F2-INTEL-001 — dispatch a synthesis-artifact review subcommand.
180
+ *
181
+ * Mirrors the finding review dispatch (the `['accept','reject','review',
182
+ * 'reopen','invalidate']` block below) but routes to `reviewArtifact`. Returns
183
+ * `true` when it handled the subcommand (and has already exited), `false`
184
+ * when `sub` was not a review verb so the caller can fall through to
185
+ * derive/list/show/etc.
186
+ *
187
+ * `type` is the artifact family ('pattern'|'recommendation'|'doctrine').
188
+ */
189
+ const ARTIFACT_REVIEW_VERBS = ['accept', 'reject', 'review', 'reopen', 'invalidate'];
190
+
191
+ function handleArtifactReview(type, sub, positional, flags) {
192
+ if (sub === 'queue') {
193
+ const queue = getArtifactReviewQueue(ROOT, type);
194
+ if (queue.length === 0) {
195
+ console.log(`${type} review queue is empty.`);
196
+ process.exit(0);
197
+ }
198
+ console.log(`${type} review queue (${queue.length} item(s)):\n`);
199
+ for (const item of queue) {
200
+ const id = item.data.pattern_id || item.data.recommendation_id || item.data.doctrine_id;
201
+ console.log(` [${item.data.status}] ${id}`);
202
+ console.log(` ${item.queueReason}`);
203
+ console.log(` ${item.data.title}`);
204
+ console.log();
205
+ }
206
+ process.exit(0);
207
+ }
208
+
209
+ if (!ARTIFACT_REVIEW_VERBS.includes(sub)) return false;
210
+
211
+ const id = positional[1];
212
+ if (!id) {
213
+ console.error(`Usage: dogfood findings ${type === 'doctrine' ? 'doctrine' : type + 's'} ${sub} <id> --actor <name> [--reason "..."]`);
214
+ process.exit(2);
215
+ }
216
+ const actor = flags.actor || 'operator';
217
+ const result = reviewArtifact(ROOT, {
218
+ type,
219
+ id,
220
+ action: sub,
221
+ actor,
222
+ reason: flags.reason,
223
+ notes: flags.notes
224
+ });
225
+ if (!result.success) {
226
+ console.error(`FAILED: ${result.error}`);
227
+ process.exit(1);
228
+ }
229
+ console.log(`${sub}: ${id} → ${result.artifact.status}`);
230
+ if (result.event) {
231
+ console.log(`Event: ${result.event.review_event_id} (${result.event.from_status} → ${result.event.to_status})`);
232
+ }
233
+ process.exit(0);
234
+ }
235
+
168
236
  async function main() {
169
237
  const { command, positional, flags } = parseArgs(process.argv);
170
238
 
@@ -189,6 +257,41 @@ Commands:
189
257
  history <id> Show review history for a finding
190
258
  queue Show review queue
191
259
 
260
+ Synthesis artifacts (patterns / recommendations / doctrine):
261
+ patterns derive [--write] Derive patterns from accepted findings
262
+ patterns list|show <id> List / inspect patterns
263
+ patterns <accept|reject|invalidate> <id> [--actor X] [--reason Y]
264
+ Review a pattern — accept PROMOTES a
265
+ candidate into the advise surface
266
+ (queryPatterns). Closes the loop.
267
+ patterns queue Patterns awaiting review (candidates)
268
+ recommendations derive [--write] | list | show <id>
269
+ recommendations <accept|reject> <id> [--actor X] [--reason Y]
270
+ recommendations apply <id> [--dry-run | --write] [--policy <org/repo>] [--actor X]
271
+ Apply an ACCEPTED recommendation back into
272
+ a repo policy. --dry-run (default) previews;
273
+ --write adds the structured target id to the
274
+ named policy's required_scenarios and records
275
+ provenance. Free-text-only / ambiguous intent
276
+ is refused with a structured hint.
277
+ recommendations queue
278
+ doctrine derive [--write] | list | show <id>
279
+ doctrine <accept|reject> <id> [--actor X] [--reason Y]
280
+ doctrine queue
281
+
282
+ Advice (read-only, derived from accepted artifacts):
283
+ advise --surface <surface> [--execution-mode <mode>] [--repo <org/repo>] [--json]
284
+ Bootstrap guidance for a new repo/surface.
285
+ --json emits the structured advice bundle
286
+ as pure JSON (pipeable; no human text on
287
+ stdout) for shipcheck / repo-knowledge.
288
+ sync-export [--json] Export accepted artifacts for downstream sync.
289
+
290
+ Note: patterns carry a literal "invalidated" status; recommendations and
291
+ doctrine do not, so invalidate is supported for patterns only. The review /
292
+ reopen verbs target the intermediate "reviewed" state, which the artifact
293
+ schemas do not allow — they are refused honestly for artifacts.
294
+
192
295
  Derive options:
193
296
  --record <run_id> Derive from a specific record
194
297
  --repo <org/repo> Derive from all records for a repo
@@ -196,6 +299,11 @@ Derive options:
196
299
  --dry-run Show what would be emitted (default)
197
300
  --write Write candidates to disk
198
301
 
302
+ Re-derivation safety:
303
+ A re-run of "<type> derive --write" will NOT overwrite an artifact you have
304
+ already promoted (accepted / rejected / invalidated). Such ids are reported as
305
+ "Preserved (operator-promoted, not overwritten)".
306
+
199
307
  Filters (for list):
200
308
  --repo <org/repo>
201
309
  --status <candidate|reviewed|accepted|rejected>
@@ -641,9 +749,23 @@ Filters (for list):
641
749
 
642
750
  if (command === 'patterns') {
643
751
  const sub = positional[0];
752
+ // F2-INTEL-001 — review verbs (accept/reject/review/reopen/invalidate) + queue.
753
+ handleArtifactReview('pattern', sub, positional, flags);
644
754
  if (sub === 'derive') {
645
755
  const write = flags.write;
646
- const { patterns, stats } = derivePatterns(ROOT, { includeFixtures: flags['include-fixtures'] });
756
+ const { patterns, skipped, stats } = derivePatterns(ROOT, { includeFixtures: flags['include-fixtures'] });
757
+
758
+ // D2B-001 — surface structured skip signal to operators. A torn accepted
759
+ // finding silently shrinks the evidence base behind a pattern's strength
760
+ // or threshold; print which file was unreadable so it appears in CI logs.
761
+ // Mirrors the recommendations/doctrine derive branches. Exit stays 0 —
762
+ // partial derivation still completes honestly against the clean findings.
763
+ if (skipped && skipped.length > 0) {
764
+ console.error(`${skipped.length} finding(s) skipped (torn/unreadable):`);
765
+ for (const s of skipped) {
766
+ console.error(` ${relative(ROOT, s.path)} — ${s.error}`);
767
+ }
768
+ }
647
769
 
648
770
  // Validate all
649
771
  const invalid = patterns.filter(p => !validatePattern(p).valid);
@@ -668,8 +790,20 @@ Filters (for list):
668
790
  console.log();
669
791
  }
670
792
 
671
- if (write && patterns.length > 0) {
672
- const { written, errors } = writePatterns(ROOT, patterns);
793
+ // F2-INTEL-002 preserve operator status across re-derivation. A
794
+ // freshly-derived candidate must NOT clobber an existing promoted
795
+ // (reviewed/accepted/rejected/invalidated) pattern on disk.
796
+ const { entries: existingPatterns } = loadPatternsWithSkips(ROOT);
797
+ const { toWrite, collisions } = dedupeArtifactsAgainstExisting(patterns, existingPatterns, 'pattern_id');
798
+ if (collisions.length > 0) {
799
+ console.log(`Preserved (operator-promoted, not overwritten): ${collisions.length}`);
800
+ for (const c of collisions) {
801
+ console.log(` ${c.id} (existing status: ${c.existingStatus})`);
802
+ }
803
+ }
804
+
805
+ if (write && toWrite.length > 0) {
806
+ const { written, errors } = writePatterns(ROOT, toWrite);
673
807
  for (const p of written) {
674
808
  console.log(`Written: ${relative(ROOT, p)}`);
675
809
  }
@@ -680,8 +814,8 @@ Filters (for list):
680
814
  }
681
815
  process.exit(1);
682
816
  }
683
- } else if (!write && patterns.length > 0) {
684
- console.log(`(dry-run) ${patterns.length} pattern(s) would be written. Use --write to materialize.`);
817
+ } else if (!write && toWrite.length > 0) {
818
+ console.log(`(dry-run) ${toWrite.length} pattern(s) would be written. Use --write to materialize.`);
685
819
  }
686
820
  process.exit(0);
687
821
  }
@@ -719,12 +853,56 @@ Filters (for list):
719
853
  process.exit(0);
720
854
  }
721
855
 
722
- console.error('Usage: dogfood findings patterns <derive|list|show|explain> [options]');
856
+ console.error('Usage: dogfood findings patterns <derive|list|show|explain|accept|reject|invalidate|queue> [options]');
723
857
  process.exit(2);
724
858
  }
725
859
 
726
860
  if (command === 'recommendations') {
727
861
  const sub = positional[0];
862
+ // F2-INTEL-001 — review verbs (accept/reject/review/reopen/invalidate) + queue.
863
+ handleArtifactReview('recommendation', sub, positional, flags);
864
+
865
+ // F2-INTEL-003 — apply an accepted recommendation back into a policy.
866
+ if (sub === 'apply') {
867
+ const id = positional[1];
868
+ if (!id) {
869
+ console.error('Usage: dogfood findings recommendations apply <id> [--dry-run | --write] [--policy <org/repo>] [--actor <name>]');
870
+ process.exit(2);
871
+ }
872
+ const mode = flags.write ? 'write' : 'dry-run';
873
+ const res = applyRecommendation(ROOT, {
874
+ id,
875
+ mode,
876
+ actor: flags.actor || 'operator',
877
+ policyRepo: flags.policy
878
+ });
879
+ if (!res.success) {
880
+ // Structured { code, message, hint } error.
881
+ console.error(`FAILED [${res.error.code}]: ${res.error.message}`);
882
+ if (res.error.hint) console.error(`Hint: ${res.error.hint}`);
883
+ process.exit(1);
884
+ }
885
+ if (mode === 'write') {
886
+ if (res.alreadyPresent) {
887
+ console.log(`apply: ${id} — "${res.provenance.target}" already present in ${flags.policy} surface ${res.provenance.surface} (no change).`);
888
+ } else {
889
+ console.log(`apply: ${id} → added "${res.provenance.target}" to ${flags.policy} surface ${res.provenance.surface}.`);
890
+ }
891
+ console.log(`Provenance: recommendation_id=${res.provenance.recommendation_id} (details recorded, not injected as logic)`);
892
+ } else {
893
+ const p = res.preview;
894
+ console.log(`(dry-run) recommendation ${id}`);
895
+ console.log(` Action: ${p.actionType} target=${p.target}`);
896
+ console.log(` Surface: ${p.surface || '(' + p.surfaces.join(', ') + ' — ambiguous)'}`);
897
+ if (p.policyPath) console.log(` Policy: ${relative(ROOT, p.policyPath)}`);
898
+ if (p.field) console.log(` Field: ${p.field}`);
899
+ console.log(` ${p.note}`);
900
+ console.log(` Details (free-text, provenance only): ${p.details}`);
901
+ console.log(`\n${p.autoApplicable ? 'Auto-applicable.' : 'Not auto-applicable.'} Use --write [--policy <org/repo>] to apply the structured intent.`);
902
+ }
903
+ process.exit(0);
904
+ }
905
+
728
906
  if (sub === 'derive') {
729
907
  const write = flags.write;
730
908
  const { recommendations, skipped, stats } = deriveRecommendations(ROOT);
@@ -732,6 +910,14 @@ Filters (for list):
732
910
  // D2B-001 — surface structured skip signal to operators. The derive
733
911
  // engine still emits clean recommendations from the patterns it could
734
912
  // read; the skipped list documents the partial-completion honestly.
913
+ //
914
+ // B003 (conscious decision): skips are NON-FATAL by design — this branch
915
+ // prints them to stderr but keeps exit 0, unlike the `derive` branch
916
+ // which exits 1 on ruleErrors (cli.js:376-382). That asymmetry is
917
+ // intentional: a torn input here degrades the synthesis corpus rather
918
+ // than corrupting it, and `findings validate` is the gate that fails the
919
+ // run on torn inputs. Do not convert this to exit 1 without a `--strict`
920
+ // opt-in; CI that must block on torn inputs should run `validate`.
735
921
  if (skipped && skipped.length > 0) {
736
922
  console.error(`${skipped.length} pattern(s) skipped (torn/unreadable):`);
737
923
  for (const s of skipped) {
@@ -748,8 +934,18 @@ Filters (for list):
748
934
  console.log();
749
935
  }
750
936
 
751
- if (write && recommendations.length > 0) {
752
- const { written, errors } = writeRecommendations(ROOT, recommendations);
937
+ // F2-INTEL-002 preserve operator status across re-derivation.
938
+ const { entries: existingRecs } = loadRecommendationsWithSkips(ROOT);
939
+ const { toWrite, collisions } = dedupeArtifactsAgainstExisting(recommendations, existingRecs, 'recommendation_id');
940
+ if (collisions.length > 0) {
941
+ console.log(`Preserved (operator-promoted, not overwritten): ${collisions.length}`);
942
+ for (const c of collisions) {
943
+ console.log(` ${c.id} (existing status: ${c.existingStatus})`);
944
+ }
945
+ }
946
+
947
+ if (write && toWrite.length > 0) {
948
+ const { written, errors } = writeRecommendations(ROOT, toWrite);
753
949
  for (const p of written) {
754
950
  console.log(`Written: ${relative(ROOT, p)}`);
755
951
  }
@@ -760,8 +956,8 @@ Filters (for list):
760
956
  }
761
957
  process.exit(1);
762
958
  }
763
- } else if (!write && recommendations.length > 0) {
764
- console.log(`(dry-run) ${recommendations.length} recommendation(s) would be written. Use --write to materialize.`);
959
+ } else if (!write && toWrite.length > 0) {
960
+ console.log(`(dry-run) ${toWrite.length} recommendation(s) would be written. Use --write to materialize.`);
765
961
  }
766
962
  process.exit(0);
767
963
  }
@@ -796,17 +992,24 @@ Filters (for list):
796
992
  process.exit(0);
797
993
  }
798
994
 
799
- console.error('Usage: dogfood findings recommendations <derive|list|show> [options]');
995
+ console.error('Usage: dogfood findings recommendations <derive|list|show|apply|accept|reject|invalidate|queue> [options]');
800
996
  process.exit(2);
801
997
  }
802
998
 
803
999
  if (command === 'doctrine') {
804
1000
  const sub = positional[0];
1001
+ // F2-INTEL-001 — review verbs (accept/reject/review/reopen/invalidate) + queue.
1002
+ handleArtifactReview('doctrine', sub, positional, flags);
805
1003
  if (sub === 'derive') {
806
1004
  const write = flags.write;
807
1005
  const { doctrines, skipped, stats } = deriveDoctrine(ROOT);
808
1006
 
809
1007
  // D2B-001 — surface structured skip signal to operators.
1008
+ //
1009
+ // B003 (conscious decision): skips are NON-FATAL by design — printed to
1010
+ // stderr but exit stays 0 (see the matching note on the recommendations
1011
+ // derive branch). `findings validate` is the gate that fails on torn
1012
+ // inputs; do not convert this to exit 1 without a `--strict` opt-in.
810
1013
  if (skipped && skipped.length > 0) {
811
1014
  console.error(`${skipped.length} pattern(s) skipped (torn/unreadable):`);
812
1015
  for (const s of skipped) {
@@ -824,8 +1027,18 @@ Filters (for list):
824
1027
  console.log();
825
1028
  }
826
1029
 
827
- if (write && doctrines.length > 0) {
828
- const { written, errors } = writeDoctrines(ROOT, doctrines);
1030
+ // F2-INTEL-002 preserve operator status across re-derivation.
1031
+ const { entries: existingDoctrines } = loadDoctrinesWithSkips(ROOT);
1032
+ const { toWrite, collisions } = dedupeArtifactsAgainstExisting(doctrines, existingDoctrines, 'doctrine_id');
1033
+ if (collisions.length > 0) {
1034
+ console.log(`Preserved (operator-promoted, not overwritten): ${collisions.length}`);
1035
+ for (const c of collisions) {
1036
+ console.log(` ${c.id} (existing status: ${c.existingStatus})`);
1037
+ }
1038
+ }
1039
+
1040
+ if (write && toWrite.length > 0) {
1041
+ const { written, errors } = writeDoctrines(ROOT, toWrite);
829
1042
  for (const p of written) {
830
1043
  console.log(`Written: ${relative(ROOT, p)}`);
831
1044
  }
@@ -836,8 +1049,8 @@ Filters (for list):
836
1049
  }
837
1050
  process.exit(1);
838
1051
  }
839
- } else if (!write && doctrines.length > 0) {
840
- console.log(`(dry-run) ${doctrines.length} doctrine(s) would be written. Use --write to materialize.`);
1052
+ } else if (!write && toWrite.length > 0) {
1053
+ console.log(`(dry-run) ${toWrite.length} doctrine(s) would be written. Use --write to materialize.`);
841
1054
  }
842
1055
  process.exit(0);
843
1056
  }
@@ -871,7 +1084,7 @@ Filters (for list):
871
1084
  process.exit(0);
872
1085
  }
873
1086
 
874
- console.error('Usage: dogfood findings doctrine <derive|list|show> [options]');
1087
+ console.error('Usage: dogfood findings doctrine <derive|list|show|accept|reject|queue> [options]');
875
1088
  process.exit(2);
876
1089
  }
877
1090
 
@@ -890,6 +1103,14 @@ Filters (for list):
890
1103
  const bundle = generateAdviceBundle(ROOT, { surface, executionMode, repo });
891
1104
  const a = bundle.advice;
892
1105
 
1106
+ // --json keeps stdout pure JSON so downstream tools (shipcheck,
1107
+ // repo-knowledge) can pipe the structured bundle without scraping the
1108
+ // human formatter. Matches the sync-export verb's flags.json idiom below.
1109
+ if (flags.json) {
1110
+ console.log(JSON.stringify(bundle, null, 2));
1111
+ process.exit(0);
1112
+ }
1113
+
893
1114
  console.log(`Advice for: ${[surface, executionMode, repo].filter(Boolean).join(', ') || 'general'}\n`);
894
1115
 
895
1116
  if (a.starter_checks.length > 0) {
package/derive/ids.js CHANGED
@@ -5,17 +5,33 @@
5
5
  * No timestamp noise in IDs.
6
6
  */
7
7
 
8
+ import { createHash } from 'node:crypto';
9
+
10
+ /**
11
+ * Delimiter for the boundary hash: a NUL code unit cannot appear in a repo
12
+ * slug (`[a-zA-Z0-9_.-]`) nor in a derived lesson slug, so joining components
13
+ * with it means no component value can forge a false boundary between them.
14
+ */
15
+ const BOUNDARY_DELIM = '\u0000';
16
+
8
17
  /**
9
18
  * Generate a stable finding ID from derivation context.
10
- * Format: dfind-<repo-slug>-<lesson-slug>
19
+ * Format: dfind-<repo-slug>-<lesson-slug>-<boundary-hash>
20
+ *
21
+ * The trailing boundary hash disambiguates inputs whose component boundary is
22
+ * itself an underscore (findings-A-002): `sanitize` folds `_` to `-`, so
23
+ * (repoSlug='a_b', lessonSlug='c') and (repoSlug='a', lessonSlug='b_c') both
24
+ * flatten the human-readable middle to `a-b-c` and previously produced the
25
+ * same id. The hash is computed over the components joined by BOUNDARY_DELIM,
26
+ * so the two tuples hash differently and never collapse to one finding_id.
11
27
  *
12
28
  * @param {string} repoSlug - e.g. "repo-crawler-mcp"
13
29
  * @param {string} lessonSlug - e.g. "surface-misclassification"
14
30
  * @returns {string}
15
31
  */
16
32
  export function generateFindingId(repoSlug, lessonSlug) {
17
- const normalized = `dfind-${sanitize(repoSlug)}-${sanitize(lessonSlug)}`;
18
- return normalized;
33
+ const boundary = boundaryHash([repoSlug, lessonSlug]);
34
+ return `dfind-${sanitize(repoSlug)}-${sanitize(lessonSlug)}-${boundary}`;
19
35
  }
20
36
 
21
37
  /**
@@ -46,3 +62,15 @@ function sanitize(s) {
46
62
  .replace(/-+/g, '-')
47
63
  .replace(/^-|-$/g, '');
48
64
  }
65
+
66
+ /**
67
+ * Short stable hash of an ordered component tuple, joined by BOUNDARY_DELIM so
68
+ * no component value can forge a false boundary. 8 lowercase-hex chars are
69
+ * ample for the finding/pattern id space (a per-repo, per-lesson namespace).
70
+ *
71
+ * @param {string[]} components
72
+ * @returns {string}
73
+ */
74
+ export function boundaryHash(components) {
75
+ return createHash('sha256').update(components.join(BOUNDARY_DELIM)).digest('hex').slice(0, 8);
76
+ }
@@ -34,6 +34,7 @@ import { mkdirSync, existsSync } from 'node:fs';
34
34
  import { resolve, dirname } from 'node:path';
35
35
  import yaml from 'js-yaml';
36
36
 
37
+ import { isUnsafeSegment } from '@dogfood-lab/ingest/lib/unsafe-segment.js';
37
38
  import { atomicWriteFileSync } from '../lib/atomic-write.js';
38
39
  import { validateFinding } from '../validate.js';
39
40
 
@@ -75,6 +76,26 @@ export class FindingIdCollisionError extends Error {
75
76
  }
76
77
  }
77
78
 
79
+ /**
80
+ * Structured error thrown when a finding's `repo` splits into an org/repo
81
+ * segment that is a path-traversal vector (`..` or a path separator).
82
+ *
83
+ * findings-A-001 — write-side traversal guard. The READ side
84
+ * (`loadRecordsForRepoWithSkips`) already rejects such segments via
85
+ * `isUnsafeSegment`; the WRITE side did not, so a schema-valid
86
+ * `repo: '../policies'` (the dogfood-finding `repo` pattern admits `.`/`..`)
87
+ * resolved one level under rootDir and wrote outside `findings/`.
88
+ */
89
+ export class FindingUnsafeRepoError extends Error {
90
+ constructor(repo, findingId) {
91
+ super(`unsafe repo path segment in finding${findingId ? ` (${findingId})` : ''}: '${repo}' contains a path-traversal or separator and was refused before any write (findings-A-001).`);
92
+ this.name = 'FindingUnsafeRepoError';
93
+ this.code = 'FINDING_UNSAFE_REPO';
94
+ this.repo = repo;
95
+ this.findingId = findingId;
96
+ }
97
+ }
98
+
78
99
  /**
79
100
  * Process-level memory of ids that have already been written via
80
101
  * `writeFinding` (singleton) OR the singleton-call inside `writeFindings`
@@ -127,6 +148,13 @@ export function writeFinding(rootDir, finding) {
127
148
  const [org, repo] = (finding.repo || '').split('/');
128
149
  if (!org || !repo) throw new Error(`Invalid repo in finding: ${finding.repo}`);
129
150
 
151
+ // findings-A-001 — write-side path-traversal guard. Mirrors the READ side
152
+ // (load-records.js loadRecordsForRepoWithSkips) so a schema-valid
153
+ // `repo: '../policies'` cannot escape `findings/` into a sibling data dir.
154
+ if (isUnsafeSegment(org) || isUnsafeSegment(repo)) {
155
+ throw new FindingUnsafeRepoError(finding.repo, finding.finding_id);
156
+ }
157
+
130
158
  // L3-001 (Wave A2 amend2): same-process same-id refusal. Programmatic
131
159
  // callers that loop over assembleFinding output now fail-closed at the
132
160
  // singleton path, not silently at the atomicWriteFileSync.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dogfood-lab/findings",
3
- "version": "1.3.2",
3
+ "version": "1.5.0",
4
4
  "type": "module",
5
5
  "description": "Finding contract spine for testing-os. Validates, reads, lists, and queries evidence-bound findings — the fourth contract alongside record, scenario, and policy.",
6
6
  "main": "index.js",
@@ -18,7 +18,7 @@
18
18
  "findings": "./cli.js"
19
19
  },
20
20
  "scripts": {
21
- "test": "node --test findings.test.js derive/derive.test.js derive/load-records-skip.test.js derive/d2b-008-collision-guard.test.js derive/d2b-002-write-schema-gate.test.js review/review.test.js review/event-log-loader.test.js review/h4-engine-auto-reject-reason.test.js synthesis/synthesis.test.js synthesis/loaders-skip.test.js synthesis/d2b-001-derive-skipped-signal.test.js advise/advise.test.js lib/atomic-write.test.js lib/safe-yaml-load.test.js lib/d1b-002-findings-sleepsync.test.js"
21
+ "test": "node --test findings.test.js derive/derive.test.js derive/load-records-skip.test.js derive/d2b-008-collision-guard.test.js derive/d2b-002-write-schema-gate.test.js review/review.test.js review/event-log-loader.test.js review/h4-engine-auto-reject-reason.test.js review/featF-intel-001-review-artifacts.test.js review/featF-intel-001-cli.test.js synthesis/synthesis.test.js synthesis/loaders-skip.test.js synthesis/d2b-001-derive-skipped-signal.test.js synthesis/featF-intel-002-preserve-operator-status.test.js synthesis/featF-intel-003-apply-recommendation.test.js advise/advise.test.js lib/atomic-write.test.js lib/safe-yaml-load.test.js lib/d1b-002-findings-sleepsync.test.js"
22
22
  },
23
23
  "files": [
24
24
  "index.js",