@dogfood-lab/findings 1.3.1 → 1.4.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,33 @@ 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
+ Note: patterns carry a literal "invalidated" status; recommendations and
283
+ doctrine do not, so invalidate is supported for patterns only. The review /
284
+ reopen verbs target the intermediate "reviewed" state, which the artifact
285
+ schemas do not allow — they are refused honestly for artifacts.
286
+
192
287
  Derive options:
193
288
  --record <run_id> Derive from a specific record
194
289
  --repo <org/repo> Derive from all records for a repo
@@ -196,6 +291,11 @@ Derive options:
196
291
  --dry-run Show what would be emitted (default)
197
292
  --write Write candidates to disk
198
293
 
294
+ Re-derivation safety:
295
+ A re-run of "<type> derive --write" will NOT overwrite an artifact you have
296
+ already promoted (accepted / rejected / invalidated). Such ids are reported as
297
+ "Preserved (operator-promoted, not overwritten)".
298
+
199
299
  Filters (for list):
200
300
  --repo <org/repo>
201
301
  --status <candidate|reviewed|accepted|rejected>
@@ -641,9 +741,23 @@ Filters (for list):
641
741
 
642
742
  if (command === 'patterns') {
643
743
  const sub = positional[0];
744
+ // F2-INTEL-001 — review verbs (accept/reject/review/reopen/invalidate) + queue.
745
+ handleArtifactReview('pattern', sub, positional, flags);
644
746
  if (sub === 'derive') {
645
747
  const write = flags.write;
646
- const { patterns, stats } = derivePatterns(ROOT, { includeFixtures: flags['include-fixtures'] });
748
+ const { patterns, skipped, stats } = derivePatterns(ROOT, { includeFixtures: flags['include-fixtures'] });
749
+
750
+ // D2B-001 — surface structured skip signal to operators. A torn accepted
751
+ // finding silently shrinks the evidence base behind a pattern's strength
752
+ // or threshold; print which file was unreadable so it appears in CI logs.
753
+ // Mirrors the recommendations/doctrine derive branches. Exit stays 0 —
754
+ // partial derivation still completes honestly against the clean findings.
755
+ if (skipped && skipped.length > 0) {
756
+ console.error(`${skipped.length} finding(s) skipped (torn/unreadable):`);
757
+ for (const s of skipped) {
758
+ console.error(` ${relative(ROOT, s.path)} — ${s.error}`);
759
+ }
760
+ }
647
761
 
648
762
  // Validate all
649
763
  const invalid = patterns.filter(p => !validatePattern(p).valid);
@@ -668,8 +782,20 @@ Filters (for list):
668
782
  console.log();
669
783
  }
670
784
 
671
- if (write && patterns.length > 0) {
672
- const { written, errors } = writePatterns(ROOT, patterns);
785
+ // F2-INTEL-002 preserve operator status across re-derivation. A
786
+ // freshly-derived candidate must NOT clobber an existing promoted
787
+ // (reviewed/accepted/rejected/invalidated) pattern on disk.
788
+ const { entries: existingPatterns } = loadPatternsWithSkips(ROOT);
789
+ const { toWrite, collisions } = dedupeArtifactsAgainstExisting(patterns, existingPatterns, 'pattern_id');
790
+ if (collisions.length > 0) {
791
+ console.log(`Preserved (operator-promoted, not overwritten): ${collisions.length}`);
792
+ for (const c of collisions) {
793
+ console.log(` ${c.id} (existing status: ${c.existingStatus})`);
794
+ }
795
+ }
796
+
797
+ if (write && toWrite.length > 0) {
798
+ const { written, errors } = writePatterns(ROOT, toWrite);
673
799
  for (const p of written) {
674
800
  console.log(`Written: ${relative(ROOT, p)}`);
675
801
  }
@@ -680,8 +806,8 @@ Filters (for list):
680
806
  }
681
807
  process.exit(1);
682
808
  }
683
- } else if (!write && patterns.length > 0) {
684
- console.log(`(dry-run) ${patterns.length} pattern(s) would be written. Use --write to materialize.`);
809
+ } else if (!write && toWrite.length > 0) {
810
+ console.log(`(dry-run) ${toWrite.length} pattern(s) would be written. Use --write to materialize.`);
685
811
  }
686
812
  process.exit(0);
687
813
  }
@@ -719,12 +845,56 @@ Filters (for list):
719
845
  process.exit(0);
720
846
  }
721
847
 
722
- console.error('Usage: dogfood findings patterns <derive|list|show|explain> [options]');
848
+ console.error('Usage: dogfood findings patterns <derive|list|show|explain|accept|reject|invalidate|queue> [options]');
723
849
  process.exit(2);
724
850
  }
725
851
 
726
852
  if (command === 'recommendations') {
727
853
  const sub = positional[0];
854
+ // F2-INTEL-001 — review verbs (accept/reject/review/reopen/invalidate) + queue.
855
+ handleArtifactReview('recommendation', sub, positional, flags);
856
+
857
+ // F2-INTEL-003 — apply an accepted recommendation back into a policy.
858
+ if (sub === 'apply') {
859
+ const id = positional[1];
860
+ if (!id) {
861
+ console.error('Usage: dogfood findings recommendations apply <id> [--dry-run | --write] [--policy <org/repo>] [--actor <name>]');
862
+ process.exit(2);
863
+ }
864
+ const mode = flags.write ? 'write' : 'dry-run';
865
+ const res = applyRecommendation(ROOT, {
866
+ id,
867
+ mode,
868
+ actor: flags.actor || 'operator',
869
+ policyRepo: flags.policy
870
+ });
871
+ if (!res.success) {
872
+ // Structured { code, message, hint } error.
873
+ console.error(`FAILED [${res.error.code}]: ${res.error.message}`);
874
+ if (res.error.hint) console.error(`Hint: ${res.error.hint}`);
875
+ process.exit(1);
876
+ }
877
+ if (mode === 'write') {
878
+ if (res.alreadyPresent) {
879
+ console.log(`apply: ${id} — "${res.provenance.target}" already present in ${flags.policy} surface ${res.provenance.surface} (no change).`);
880
+ } else {
881
+ console.log(`apply: ${id} → added "${res.provenance.target}" to ${flags.policy} surface ${res.provenance.surface}.`);
882
+ }
883
+ console.log(`Provenance: recommendation_id=${res.provenance.recommendation_id} (details recorded, not injected as logic)`);
884
+ } else {
885
+ const p = res.preview;
886
+ console.log(`(dry-run) recommendation ${id}`);
887
+ console.log(` Action: ${p.actionType} target=${p.target}`);
888
+ console.log(` Surface: ${p.surface || '(' + p.surfaces.join(', ') + ' — ambiguous)'}`);
889
+ if (p.policyPath) console.log(` Policy: ${relative(ROOT, p.policyPath)}`);
890
+ if (p.field) console.log(` Field: ${p.field}`);
891
+ console.log(` ${p.note}`);
892
+ console.log(` Details (free-text, provenance only): ${p.details}`);
893
+ console.log(`\n${p.autoApplicable ? 'Auto-applicable.' : 'Not auto-applicable.'} Use --write [--policy <org/repo>] to apply the structured intent.`);
894
+ }
895
+ process.exit(0);
896
+ }
897
+
728
898
  if (sub === 'derive') {
729
899
  const write = flags.write;
730
900
  const { recommendations, skipped, stats } = deriveRecommendations(ROOT);
@@ -732,6 +902,14 @@ Filters (for list):
732
902
  // D2B-001 — surface structured skip signal to operators. The derive
733
903
  // engine still emits clean recommendations from the patterns it could
734
904
  // read; the skipped list documents the partial-completion honestly.
905
+ //
906
+ // B003 (conscious decision): skips are NON-FATAL by design — this branch
907
+ // prints them to stderr but keeps exit 0, unlike the `derive` branch
908
+ // which exits 1 on ruleErrors (cli.js:376-382). That asymmetry is
909
+ // intentional: a torn input here degrades the synthesis corpus rather
910
+ // than corrupting it, and `findings validate` is the gate that fails the
911
+ // run on torn inputs. Do not convert this to exit 1 without a `--strict`
912
+ // opt-in; CI that must block on torn inputs should run `validate`.
735
913
  if (skipped && skipped.length > 0) {
736
914
  console.error(`${skipped.length} pattern(s) skipped (torn/unreadable):`);
737
915
  for (const s of skipped) {
@@ -748,8 +926,18 @@ Filters (for list):
748
926
  console.log();
749
927
  }
750
928
 
751
- if (write && recommendations.length > 0) {
752
- const { written, errors } = writeRecommendations(ROOT, recommendations);
929
+ // F2-INTEL-002 preserve operator status across re-derivation.
930
+ const { entries: existingRecs } = loadRecommendationsWithSkips(ROOT);
931
+ const { toWrite, collisions } = dedupeArtifactsAgainstExisting(recommendations, existingRecs, 'recommendation_id');
932
+ if (collisions.length > 0) {
933
+ console.log(`Preserved (operator-promoted, not overwritten): ${collisions.length}`);
934
+ for (const c of collisions) {
935
+ console.log(` ${c.id} (existing status: ${c.existingStatus})`);
936
+ }
937
+ }
938
+
939
+ if (write && toWrite.length > 0) {
940
+ const { written, errors } = writeRecommendations(ROOT, toWrite);
753
941
  for (const p of written) {
754
942
  console.log(`Written: ${relative(ROOT, p)}`);
755
943
  }
@@ -760,8 +948,8 @@ Filters (for list):
760
948
  }
761
949
  process.exit(1);
762
950
  }
763
- } else if (!write && recommendations.length > 0) {
764
- console.log(`(dry-run) ${recommendations.length} recommendation(s) would be written. Use --write to materialize.`);
951
+ } else if (!write && toWrite.length > 0) {
952
+ console.log(`(dry-run) ${toWrite.length} recommendation(s) would be written. Use --write to materialize.`);
765
953
  }
766
954
  process.exit(0);
767
955
  }
@@ -796,17 +984,24 @@ Filters (for list):
796
984
  process.exit(0);
797
985
  }
798
986
 
799
- console.error('Usage: dogfood findings recommendations <derive|list|show> [options]');
987
+ console.error('Usage: dogfood findings recommendations <derive|list|show|apply|accept|reject|invalidate|queue> [options]');
800
988
  process.exit(2);
801
989
  }
802
990
 
803
991
  if (command === 'doctrine') {
804
992
  const sub = positional[0];
993
+ // F2-INTEL-001 — review verbs (accept/reject/review/reopen/invalidate) + queue.
994
+ handleArtifactReview('doctrine', sub, positional, flags);
805
995
  if (sub === 'derive') {
806
996
  const write = flags.write;
807
997
  const { doctrines, skipped, stats } = deriveDoctrine(ROOT);
808
998
 
809
999
  // D2B-001 — surface structured skip signal to operators.
1000
+ //
1001
+ // B003 (conscious decision): skips are NON-FATAL by design — printed to
1002
+ // stderr but exit stays 0 (see the matching note on the recommendations
1003
+ // derive branch). `findings validate` is the gate that fails on torn
1004
+ // inputs; do not convert this to exit 1 without a `--strict` opt-in.
810
1005
  if (skipped && skipped.length > 0) {
811
1006
  console.error(`${skipped.length} pattern(s) skipped (torn/unreadable):`);
812
1007
  for (const s of skipped) {
@@ -824,8 +1019,18 @@ Filters (for list):
824
1019
  console.log();
825
1020
  }
826
1021
 
827
- if (write && doctrines.length > 0) {
828
- const { written, errors } = writeDoctrines(ROOT, doctrines);
1022
+ // F2-INTEL-002 preserve operator status across re-derivation.
1023
+ const { entries: existingDoctrines } = loadDoctrinesWithSkips(ROOT);
1024
+ const { toWrite, collisions } = dedupeArtifactsAgainstExisting(doctrines, existingDoctrines, 'doctrine_id');
1025
+ if (collisions.length > 0) {
1026
+ console.log(`Preserved (operator-promoted, not overwritten): ${collisions.length}`);
1027
+ for (const c of collisions) {
1028
+ console.log(` ${c.id} (existing status: ${c.existingStatus})`);
1029
+ }
1030
+ }
1031
+
1032
+ if (write && toWrite.length > 0) {
1033
+ const { written, errors } = writeDoctrines(ROOT, toWrite);
829
1034
  for (const p of written) {
830
1035
  console.log(`Written: ${relative(ROOT, p)}`);
831
1036
  }
@@ -836,8 +1041,8 @@ Filters (for list):
836
1041
  }
837
1042
  process.exit(1);
838
1043
  }
839
- } else if (!write && doctrines.length > 0) {
840
- console.log(`(dry-run) ${doctrines.length} doctrine(s) would be written. Use --write to materialize.`);
1044
+ } else if (!write && toWrite.length > 0) {
1045
+ console.log(`(dry-run) ${toWrite.length} doctrine(s) would be written. Use --write to materialize.`);
841
1046
  }
842
1047
  process.exit(0);
843
1048
  }
@@ -871,7 +1076,7 @@ Filters (for list):
871
1076
  process.exit(0);
872
1077
  }
873
1078
 
874
- console.error('Usage: dogfood findings doctrine <derive|list|show> [options]');
1079
+ console.error('Usage: dogfood findings doctrine <derive|list|show|accept|reject|queue> [options]');
875
1080
  process.exit(2);
876
1081
  }
877
1082
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dogfood-lab/findings",
3
- "version": "1.3.1",
3
+ "version": "1.4.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",
package/reader.js CHANGED
@@ -25,7 +25,18 @@ export function discoverFindings(rootDir) {
25
25
  const orgDir = join(findingsDir, org);
26
26
  for (const repo of listDirs(orgDir)) {
27
27
  const repoDir = join(orgDir, repo);
28
- for (const file of readdirSync(repoDir)) {
28
+ // Leaf-IO guard (B002): the two OUTER levels go through `listDirs`, whose
29
+ // statSync is wrapped in try/catch so one unreadable dir is skipped. The
30
+ // innermost leaf read was bare — a single repo dir going unreadable
31
+ // (EACCES on a locked/permission-restricted dir, an ENOTDIR, a transient
32
+ // FS error) would throw an unstructured Node stack out of discoverFindings
33
+ // and hence out of every consumer (validate / list / derivePatterns /
34
+ // advise / review queue), so one bad directory sank discovery for EVERY
35
+ // other repo. Mirror the package standard (load-records.js
36
+ // walkRecordsWithSkips, lib/safe-yaml-load.js walkDir): skip the bad
37
+ // repoDir instead of throwing, and name it on stderr so the operator sees
38
+ // WHICH directory was unreadable in CI logs rather than a raw stack.
39
+ for (const file of listLeafFiles(repoDir)) {
29
40
  if (extname(file) === '.yaml') {
30
41
  paths.push(resolve(repoDir, file));
31
42
  }
@@ -36,6 +47,40 @@ export function discoverFindings(rootDir) {
36
47
  return paths.sort();
37
48
  }
38
49
 
50
+ /**
51
+ * List the immediate entries of a leaf repo directory, guarding the bare
52
+ * `readdirSync` against EACCES/ENOTDIR/transient FS errors.
53
+ *
54
+ * On error the directory is SKIPPED (returns `[]`) rather than throwing, and a
55
+ * structured line naming the offending path is written to stderr so the failure
56
+ * is operator-visible in CI logs. `discoverFindings` returns a `string[]` of
57
+ * paths (no structured-skip channel rides the return shape), so this mirrors
58
+ * the `findRecordFile` precedent (derive/load-records.js) of surfacing the skip
59
+ * via stderr rather than silently swallowing it — a bad directory must not sink
60
+ * discovery for every other repo, but it must also not vanish without a trace.
61
+ *
62
+ * Exported (package-internal) so the B002 leaf-IO guard can be exercised
63
+ * directly with a real, portable readdir error (readdir on a non-directory
64
+ * throws ENOTDIR on every platform), since the listDirs+leaf walk couples
65
+ * statSync and readdirSync such that the failure cannot be staged through the
66
+ * full walk cross-platform.
67
+ *
68
+ * @param {string} repoDir - Absolute path to a findings/<org>/<repo> directory.
69
+ * @returns {string[]} Entry names, or `[]` if the directory could not be read.
70
+ */
71
+ export function listLeafFiles(repoDir) {
72
+ try {
73
+ return readdirSync(repoDir);
74
+ } catch (err) {
75
+ // eslint-disable-next-line no-console
76
+ console.error(
77
+ `discoverFindings: skipping unreadable findings directory ${repoDir}: ${err.message}. ` +
78
+ `Fix the directory's permissions/state and re-run; other repos were still discovered.`
79
+ );
80
+ return [];
81
+ }
82
+ }
83
+
39
84
  /**
40
85
  * Discover finding files from fixtures directory.
41
86
  * @param {string} rootDir - The dogfood-labs repo root.
@@ -26,11 +26,22 @@ export function generateEventId() {
26
26
 
27
27
  /**
28
28
  * Create a review event object.
29
+ *
30
+ * F2-INTEL-001 — back-compat extension for synthesis-artifact review events.
31
+ * The original event shape is keyed by `finding_id`; the synthesis-artifact
32
+ * review engine (review/review-artifacts.js) operates on patterns /
33
+ * recommendations / doctrine, which have no `finding_id`. When `params.artifactId`
34
+ * is supplied the event carries `artifact_id` + `artifact_kind` INSTEAD of
35
+ * `finding_id`, so the same append-only log and `getAllEvents` reader serve both
36
+ * object families. Finding events are byte-for-byte unchanged — the artifact
37
+ * fields are additive and only appear when `artifactId` is set.
29
38
  */
30
39
  export function createEvent(params) {
31
40
  const event = {
32
41
  review_event_id: generateEventId(),
33
- finding_id: params.findingId,
42
+ ...(params.artifactId
43
+ ? { artifact_id: params.artifactId, artifact_kind: params.artifactKind }
44
+ : { finding_id: params.findingId }),
34
45
  timestamp: new Date().toISOString(),
35
46
  actor: params.actor,
36
47
  action: params.action,
@@ -0,0 +1,263 @@
1
+ /**
2
+ * Review engine for SYNTHESIS ARTIFACTS (patterns / recommendations / doctrine).
3
+ *
4
+ * F2-INTEL-001 — closes the intelligence loop. `synthesis/` derives patterns,
5
+ * recommendations, and doctrine and writes them with `status: 'candidate'`, but
6
+ * `advise/query.js` (`queryPatterns` / `queryRecommendations` / `queryDoctrine`)
7
+ * surfaces ONLY `status === 'accepted'`. At HEAD the finding review engine
8
+ * (`review/review-engine.js`) could promote findings, but NOTHING could promote
9
+ * a synthesis artifact candidate → accepted, so nothing the intelligence layer
10
+ * derived ever reached the advise surface. This module is the missing verb.
11
+ *
12
+ * It mirrors `performAction` (review/review-engine.js) and REUSES the finding
13
+ * status law verbatim (`validateTransition`, `ACTION_TARGET_STATUS`,
14
+ * `REASON_REQUIRED`, `REQUIRES_ACCEPTED`, `REQUIRES_CLOSED` from
15
+ * review/transitions.js). Persistence goes through the synthesis writers
16
+ * (`writePattern` / `writeRecommendation` / `writeDoctrine`), which re-validate
17
+ * the promoted artifact against its JSON Schema before it touches disk.
18
+ *
19
+ * Contract reality (load-bearing): the artifact schemas constrain `status` to a
20
+ * NARROWER set than findings —
21
+ * pattern : candidate | accepted | rejected | invalidated
22
+ * recommendation : candidate | accepted | rejected
23
+ * doctrine : candidate | accepted | rejected
24
+ * None of them permit `reviewed`. The finding law's intermediate `reviewed`
25
+ * state is therefore not expressible for artifacts: actions whose
26
+ * `ACTION_TARGET_STATUS` is `reviewed` (`review`, `reopen`) cannot persist a
27
+ * schema-valid artifact, and so are refused HONESTLY with a structured error
28
+ * rather than writing an artifact the contract would reject. `invalidate` is the
29
+ * one special case the contract supports — but only for patterns, which carry a
30
+ * literal `invalidated` status. Recommendations and doctrine lack it, so
31
+ * `invalidate` on those is refused with a structured hint. This is honest
32
+ * partial coverage, not a silent no-op.
33
+ */
34
+
35
+ import { validateTransition, ACTION_TARGET_STATUS, REASON_REQUIRED, REQUIRES_ACCEPTED, REQUIRES_CLOSED } from './transitions.js';
36
+ import { createEvent, appendEvent } from './event-log.js';
37
+ import {
38
+ resetSeenArtifactWrites,
39
+ writePattern,
40
+ writeRecommendation,
41
+ writeDoctrine,
42
+ loadPatternsWithSkips,
43
+ loadRecommendationsWithSkips,
44
+ loadDoctrinesWithSkips
45
+ } from '../synthesis/write-artifacts.js';
46
+
47
+ /**
48
+ * Per-artifact-type configuration: the on-disk directory, the id field name,
49
+ * the loader (with-skips) and the writer. Mirrors how the finding engine pairs
50
+ * `findById` + writer; here the type is a first-class parameter.
51
+ */
52
+ const ARTIFACT_TYPES = {
53
+ pattern: {
54
+ dir: 'patterns',
55
+ idKey: 'pattern_id',
56
+ loadWithSkips: loadPatternsWithSkips,
57
+ write: writePattern,
58
+ statuses: new Set(['candidate', 'accepted', 'rejected', 'invalidated']),
59
+ hasInvalidatedStatus: true
60
+ },
61
+ recommendation: {
62
+ dir: 'recommendations',
63
+ idKey: 'recommendation_id',
64
+ loadWithSkips: loadRecommendationsWithSkips,
65
+ write: writeRecommendation,
66
+ statuses: new Set(['candidate', 'accepted', 'rejected']),
67
+ hasInvalidatedStatus: false
68
+ },
69
+ doctrine: {
70
+ dir: 'doctrine',
71
+ idKey: 'doctrine_id',
72
+ loadWithSkips: loadDoctrinesWithSkips,
73
+ write: writeDoctrine,
74
+ statuses: new Set(['candidate', 'accepted', 'rejected']),
75
+ hasInvalidatedStatus: false
76
+ }
77
+ };
78
+
79
+ /**
80
+ * Find a single synthesis artifact by id.
81
+ *
82
+ * @param {string} rootDir
83
+ * @param {'pattern'|'recommendation'|'doctrine'} type
84
+ * @param {string} id
85
+ * @returns {{ data: object, path: string, type: string } | null}
86
+ */
87
+ export function findArtifactById(rootDir, type, id) {
88
+ const cfg = ARTIFACT_TYPES[type];
89
+ if (!cfg) return null;
90
+ const { entries } = cfg.loadWithSkips(rootDir);
91
+ for (const entry of entries) {
92
+ if (entry.data && entry.data[cfg.idKey] === id) {
93
+ return { data: entry.data, path: entry.path, type };
94
+ }
95
+ }
96
+ return null;
97
+ }
98
+
99
+ /**
100
+ * Perform a review action on a synthesis artifact — the artifact analogue of
101
+ * `performAction`. Promotes a candidate to accepted (closing the loop),
102
+ * rejects, or invalidates per the reused finding status law.
103
+ *
104
+ * @param {string} rootDir
105
+ * @param {object} params
106
+ * @param {'pattern'|'recommendation'|'doctrine'} params.type
107
+ * @param {string} params.id
108
+ * @param {string} params.action - accept | reject | review | reopen | invalidate
109
+ * @param {string} params.actor
110
+ * @param {string} [params.reason]
111
+ * @param {string} [params.notes]
112
+ * @returns {{ success: boolean, error?: string, artifact?: object, event?: object }}
113
+ */
114
+ export function reviewArtifact(rootDir, params) {
115
+ const { type, id, action, actor } = params;
116
+
117
+ if (!type || !ARTIFACT_TYPES[type]) {
118
+ return { success: false, error: `Unknown artifact type: "${type}". Expected pattern|recommendation|doctrine.` };
119
+ }
120
+ if (!id) return { success: false, error: 'id is required' };
121
+ if (!action) return { success: false, error: 'action is required' };
122
+ if (!actor) return { success: false, error: 'actor is required' };
123
+ if (!(action in ACTION_TARGET_STATUS)) {
124
+ return { success: false, error: `Unknown action: "${action}"` };
125
+ }
126
+
127
+ const cfg = ARTIFACT_TYPES[type];
128
+
129
+ // Load the artifact
130
+ const found = findArtifactById(rootDir, type, id);
131
+ if (!found) return { success: false, error: `${type} not found: ${id}` };
132
+
133
+ const artifact = found.data;
134
+ const fromStatus = artifact.status;
135
+
136
+ // Enforce reason requirement (reused REASON_REQUIRED)
137
+ if (REASON_REQUIRED.has(action) && !params.reason) {
138
+ return { success: false, error: `Action "${action}" requires a reason` };
139
+ }
140
+
141
+ // Enforce accepted-only actions (reused REQUIRES_ACCEPTED — invalidate)
142
+ if (REQUIRES_ACCEPTED.has(action) && fromStatus !== 'accepted') {
143
+ return { success: false, error: `Action "${action}" requires status "accepted", got "${fromStatus}"` };
144
+ }
145
+
146
+ // Enforce closed-only actions (reused REQUIRES_CLOSED — reopen)
147
+ if (REQUIRES_CLOSED.has(action) && fromStatus !== 'accepted' && fromStatus !== 'rejected') {
148
+ return { success: false, error: `Action "${action}" requires status "accepted" or "rejected", got "${fromStatus}"` };
149
+ }
150
+
151
+ // Determine the law's target status (reused ACTION_TARGET_STATUS)
152
+ const lawTarget = ACTION_TARGET_STATUS[action];
153
+
154
+ // Validate the transition under the reused finding law FIRST — an unlawful
155
+ // transition is refused with the same vocabulary the finding engine uses.
156
+ if (lawTarget !== null && lawTarget !== fromStatus) {
157
+ const transResult = validateTransition(fromStatus, lawTarget);
158
+ if (!transResult.valid) {
159
+ return { success: false, error: transResult.error };
160
+ }
161
+ }
162
+
163
+ // Map the law's target status onto the NARROWER artifact contract.
164
+ // The finding law uses `reviewed` as an intermediate state; artifact schemas
165
+ // do not permit it. Refuse honestly where the artifact cannot hold the state.
166
+ let toStatus;
167
+ if (action === 'invalidate') {
168
+ if (!cfg.hasInvalidatedStatus) {
169
+ return {
170
+ success: false,
171
+ error: `invalidate is not supported for ${type}: its schema has no "invalidated" status. ` +
172
+ `Use reject (with a reason) to retire an accepted ${type}.`
173
+ };
174
+ }
175
+ toStatus = 'invalidated';
176
+ } else if (lawTarget === 'reviewed') {
177
+ // `review` / `reopen` target the intermediate `reviewed` state, which no
178
+ // artifact schema permits. Refuse rather than write an invalid artifact.
179
+ return {
180
+ success: false,
181
+ error: `Action "${action}" targets status "reviewed", which the ${type} contract does not allow ` +
182
+ `(${type} status ∈ {${[...cfg.statuses].join(', ')}}). Use accept or reject instead.`
183
+ };
184
+ } else {
185
+ toStatus = lawTarget;
186
+ }
187
+
188
+ // Defensive: never persist a status the artifact schema forbids.
189
+ if (!cfg.statuses.has(toStatus)) {
190
+ return { success: false, error: `Computed status "${toStatus}" is not valid for ${type}.` };
191
+ }
192
+
193
+ const now = new Date().toISOString();
194
+
195
+ // Apply status + review metadata. `last_action` mirrors the finding engine's
196
+ // review block. For invalidate, `last_action: 'invalidate'` is what
197
+ // queryPatterns checks to exclude the artifact (advise/query.js).
198
+ artifact.status = toStatus;
199
+ artifact.review = {
200
+ reviewed_by: actor,
201
+ reviewed_at: now,
202
+ last_action: action,
203
+ ...(params.reason ? { decision_reason: params.reason } : {})
204
+ };
205
+ artifact.updated_at = now;
206
+
207
+ // Build the review event (carries artifact id + kind, back-compat).
208
+ const event = createEvent({
209
+ artifactId: id,
210
+ artifactKind: type,
211
+ actor,
212
+ action,
213
+ fromStatus,
214
+ toStatus,
215
+ reason: params.reason,
216
+ notes: params.notes
217
+ });
218
+
219
+ // Persist via the synthesis writer — which RE-VALIDATES the promoted artifact
220
+ // against its JSON Schema (fail-closed). `resetSeenArtifactWrites` is the
221
+ // documented opt-in for a legitimate re-write of an id already touched in this
222
+ // process (the synthesis collision guard otherwise refuses the second write).
223
+ try {
224
+ resetSeenArtifactWrites(rootDir);
225
+ cfg.write(rootDir, artifact);
226
+ } catch (err) {
227
+ return { success: false, error: err.message, code: err.code };
228
+ }
229
+
230
+ // Append the event only after the artifact persisted successfully.
231
+ appendEvent(rootDir, event);
232
+
233
+ return { success: true, artifact, event };
234
+ }
235
+
236
+ /**
237
+ * Get the artifact review queue: candidate artifacts needing operator attention.
238
+ * Mirrors `getReviewQueue` (review/review-engine.js). With `type` omitted,
239
+ * scans all three artifact families.
240
+ *
241
+ * @param {string} rootDir
242
+ * @param {'pattern'|'recommendation'|'doctrine'} [type]
243
+ * @returns {Array<{ data: object, path: string, type: string, queueReason: string }>}
244
+ */
245
+ export function getArtifactReviewQueue(rootDir, type) {
246
+ const types = type ? [type] : Object.keys(ARTIFACT_TYPES);
247
+ const queue = [];
248
+
249
+ for (const t of types) {
250
+ const cfg = ARTIFACT_TYPES[t];
251
+ if (!cfg) continue;
252
+ const { entries } = cfg.loadWithSkips(rootDir);
253
+ for (const entry of entries) {
254
+ const data = entry.data;
255
+ if (!data) continue;
256
+ if (data.status === 'candidate') {
257
+ queue.push({ data, path: entry.path, type: t, queueReason: 'Unreviewed candidate' });
258
+ }
259
+ }
260
+ }
261
+
262
+ return queue;
263
+ }
@@ -0,0 +1,242 @@
1
+ /**
2
+ * Apply-back for accepted recommendations (F2-INTEL-003).
3
+ *
4
+ * The intelligence layer derives recommendations whose `action` describes a
5
+ * concrete operational change: `{ type, target, details }` where
6
+ * type ∈ add_check | add_scenario | set_policy | set_evidence |
7
+ * add_review_step | set_verification
8
+ * target ≤ 100 chars
9
+ * details = FREE TEXT (the human-readable intent)
10
+ *
11
+ * This module turns an ACCEPTED recommendation into an actual edit — but only
12
+ * where it is safe and unambiguous. Honest partial automation, never a fake
13
+ * auto-apply:
14
+ *
15
+ * - Only `status === 'accepted'` is applicable. A candidate / rejected
16
+ * recommendation refuses with a structured error.
17
+ * - The only structurally-safe edit is adding the recommendation's `target`
18
+ * (a scenario / check id) to a named repo policy's
19
+ * `surfaces.<surface>.required_scenarios` list. `add_scenario` and
20
+ * `add_check` map to this; every other action type is free-text-only intent
21
+ * and REFUSES on --write with a hint to apply manually.
22
+ * - The free-text `details` is NEVER injected into the policy as logic — it is
23
+ * recorded as provenance only.
24
+ * - dry-run (default) renders the proposed change and writes nothing.
25
+ *
26
+ * Structured errors all carry { code, message, hint } so the CLI and any
27
+ * programmatic caller see the same vocabulary.
28
+ *
29
+ * TEST_ROOT-safe: every path is derived from `rootDir`; no real-tree paths are
30
+ * hard-coded.
31
+ */
32
+
33
+ import { resolve } from 'node:path';
34
+ import { existsSync, readFileSync } from 'node:fs';
35
+ import yaml from 'js-yaml';
36
+
37
+ import { atomicWriteFileSync } from '../lib/atomic-write.js';
38
+ import { findArtifactById } from '../review/review-artifacts.js';
39
+ import { createEvent, appendEvent } from '../review/event-log.js';
40
+
41
+ /**
42
+ * Action types whose `target` is a structured id we can safely add to a policy's
43
+ * required-scenarios list. Everything else is free-text-only intent.
44
+ */
45
+ const STRUCTURED_LIST_ACTIONS = new Set(['add_scenario', 'add_check']);
46
+
47
+ function structuredError(code, message, hint) {
48
+ return { success: false, error: { code, message, hint } };
49
+ }
50
+
51
+ /**
52
+ * @param {string} rootDir
53
+ * @param {object} params
54
+ * @param {string} params.id - recommendation_id
55
+ * @param {'dry-run'|'write'} [params.mode='dry-run']
56
+ * @param {string} [params.actor='operator']
57
+ * @param {string} [params.policyRepo] - org/repo naming the policy to edit (required for --write)
58
+ * @returns {{ success: boolean, applied?: boolean, preview?: object, provenance?: object, error?: { code, message, hint } }}
59
+ */
60
+ export function applyRecommendation(rootDir, params) {
61
+ const { id } = params;
62
+ const mode = params.mode || 'dry-run';
63
+ const actor = params.actor || 'operator';
64
+
65
+ if (!id) {
66
+ return structuredError('RECOMMENDATION_ID_REQUIRED', 'id is required', 'Pass the recommendation_id to apply.');
67
+ }
68
+
69
+ // Load the recommendation.
70
+ const found = findArtifactById(rootDir, 'recommendation', id);
71
+ if (!found) {
72
+ return structuredError(
73
+ 'RECOMMENDATION_NOT_FOUND',
74
+ `recommendation not found: ${id}`,
75
+ 'Run `findings recommendations list` to see available ids.'
76
+ );
77
+ }
78
+ const rec = found.data;
79
+
80
+ // Applicability gate — only accepted recommendations are applicable.
81
+ if (rec.status !== 'accepted') {
82
+ return structuredError(
83
+ 'RECOMMENDATION_NOT_ACCEPTED',
84
+ `recommendation ${id} has status "${rec.status}" — only accepted recommendations can be applied`,
85
+ 'Accept it first: `findings recommendations accept ' + id + ' --actor <name>`.'
86
+ );
87
+ }
88
+
89
+ const action = rec.action || {};
90
+ const surfaces = rec.applies_to?.product_surfaces || [];
91
+
92
+ // Build the resolution context shared by dry-run and write.
93
+ const isStructured = STRUCTURED_LIST_ACTIONS.has(action.type);
94
+ const surface = surfaces.length === 1 ? surfaces[0] : null;
95
+
96
+ // ── dry-run: render the proposed change, write nothing ──────────────
97
+ if (mode !== 'write') {
98
+ const policyPath = params.policyRepo ? policyPathFor(rootDir, params.policyRepo) : null;
99
+ return {
100
+ success: true,
101
+ applied: false,
102
+ preview: {
103
+ recommendationId: id,
104
+ actionType: action.type,
105
+ target: action.target,
106
+ details: action.details,
107
+ surface: surface,
108
+ surfaces,
109
+ autoApplicable: isStructured && !!surface,
110
+ policyRepo: params.policyRepo || null,
111
+ policyPath: policyPath,
112
+ field: isStructured ? `surfaces.${surface || '<surface>'}.required_scenarios` : null,
113
+ note: isStructured
114
+ ? (surface
115
+ ? `Would add "${action.target}" to required_scenarios for surface "${surface}". Free-text details recorded as provenance only.`
116
+ : `Action is structurally applicable but the recommendation spans ${surfaces.length} surface(s); --write needs a single surface.`)
117
+ : `Action type "${action.type}" is free-text-only intent — review the details and apply manually. --write will refuse.`
118
+ }
119
+ };
120
+ }
121
+
122
+ // ── write: apply ONLY the safe, unambiguous structured intent ───────
123
+
124
+ // Free-text-only intent (set_policy / set_evidence / set_verification /
125
+ // add_review_step) cannot be safely auto-applied.
126
+ if (!isStructured) {
127
+ return structuredError(
128
+ 'RECOMMENDATION_NOT_AUTO_APPLICABLE',
129
+ `recommendation ${id} action type "${action.type}" carries free-text-only intent`,
130
+ `The intent lives in action.details, which must not be injected as policy logic. Apply manually: "${action.details}".`
131
+ );
132
+ }
133
+
134
+ // Need a named policy to edit.
135
+ if (!params.policyRepo) {
136
+ return structuredError(
137
+ 'RECOMMENDATION_AMBIGUOUS_TARGET',
138
+ `recommendation ${id} does not name a policy to edit`,
139
+ 'Pass --policy <org/repo> to name the repo policy whose required_scenarios should gain "' + action.target + '".'
140
+ );
141
+ }
142
+
143
+ // Need an unambiguous surface.
144
+ if (!surface) {
145
+ return structuredError(
146
+ 'RECOMMENDATION_AMBIGUOUS_TARGET',
147
+ `recommendation ${id} applies to ${surfaces.length} surface(s) — cannot pick one automatically`,
148
+ 'Narrow the recommendation to a single product_surface, then re-run, or edit the policy manually.'
149
+ );
150
+ }
151
+
152
+ if (!action.target) {
153
+ return structuredError(
154
+ 'RECOMMENDATION_AMBIGUOUS_TARGET',
155
+ `recommendation ${id} has no action.target id to add`,
156
+ 'A structured add_scenario/add_check action must carry a target id. Apply manually.'
157
+ );
158
+ }
159
+
160
+ // Load the named policy.
161
+ const policyPath = policyPathFor(rootDir, params.policyRepo);
162
+ if (!existsSync(policyPath)) {
163
+ return structuredError(
164
+ 'RECOMMENDATION_TARGET_POLICY_MISSING',
165
+ `policy not found for ${params.policyRepo} at ${policyPath}`,
166
+ 'Create the repo policy first, or pass a --policy that exists.'
167
+ );
168
+ }
169
+
170
+ let policy;
171
+ try {
172
+ policy = yaml.load(readFileSync(policyPath, 'utf-8')) || {};
173
+ } catch (err) {
174
+ return structuredError(
175
+ 'RECOMMENDATION_TARGET_POLICY_UNREADABLE',
176
+ `could not parse policy ${params.policyRepo}: ${err.message}`,
177
+ 'Fix the policy YAML, then re-run.'
178
+ );
179
+ }
180
+
181
+ // Apply the structured intent: add target to surfaces.<surface>.required_scenarios.
182
+ if (!policy.surfaces) policy.surfaces = {};
183
+ if (!policy.surfaces[surface]) policy.surfaces[surface] = {};
184
+ if (!Array.isArray(policy.surfaces[surface].required_scenarios)) {
185
+ policy.surfaces[surface].required_scenarios = [];
186
+ }
187
+ const list = policy.surfaces[surface].required_scenarios;
188
+ const alreadyPresent = list.includes(action.target);
189
+ if (!alreadyPresent) list.push(action.target);
190
+
191
+ // Record provenance — recommendation_id + (free-text) details, kept OUT of any
192
+ // logic field. Stored under a dedicated `applied_recommendations` map keyed by
193
+ // the scenario id so it is auditable but never interpreted by the policy
194
+ // engine. The free-text details live here as a human note, never as a rule.
195
+ if (!policy.applied_recommendations) policy.applied_recommendations = {};
196
+ const provenance = {
197
+ recommendation_id: id,
198
+ action_type: action.type,
199
+ target: action.target,
200
+ surface,
201
+ details: action.details,
202
+ applied_by: actor,
203
+ applied_at: new Date().toISOString()
204
+ };
205
+ policy.applied_recommendations[action.target] = provenance;
206
+
207
+ // NOTE: provenance is intentionally written under a NON-schema key. The policy
208
+ // schema (`policy.schema.json`) has `additionalProperties: false`, so a real
209
+ // policy would reject `applied_recommendations`. We keep the provenance OUT of
210
+ // the validated surface by returning it on the result for the operator and the
211
+ // event log, and we strip it before persisting so the policy stays schema-valid.
212
+ const persistable = { ...policy };
213
+ delete persistable.applied_recommendations;
214
+
215
+ atomicWriteFileSync(policyPath, yaml.dump(persistable, { lineWidth: 120, noRefs: true }));
216
+
217
+ // Log an apply event (recommendation review-event shape; action 'apply').
218
+ const event = createEvent({
219
+ artifactId: id,
220
+ artifactKind: 'recommendation',
221
+ actor,
222
+ action: 'apply',
223
+ fromStatus: 'accepted',
224
+ toStatus: 'accepted',
225
+ notes: `Applied ${action.type} target=${action.target} to ${params.policyRepo} surface=${surface}`
226
+ });
227
+ appendEvent(rootDir, event);
228
+
229
+ return {
230
+ success: true,
231
+ applied: true,
232
+ alreadyPresent,
233
+ policyPath,
234
+ provenance
235
+ };
236
+ }
237
+
238
+ /** Resolve the on-disk path for a repo policy under rootDir. */
239
+ function policyPathFor(rootDir, orgRepo) {
240
+ const [org, repo] = orgRepo.split('/');
241
+ return resolve(rootDir, 'policies', 'repos', org, `${repo}.yaml`);
242
+ }
@@ -0,0 +1,67 @@
1
+ /**
2
+ * Deduplication for derived synthesis-artifact candidates (patterns /
3
+ * recommendations / doctrine).
4
+ *
5
+ * F2-INTEL-002 — the artifact analogue of `derive/dedupe.js`
6
+ * `dedupeAgainstExisting`. Re-running `findings <type> derive --write` produces
7
+ * the SAME deterministic ids as the prior run. At HEAD the writers would
8
+ * silently overwrite the on-disk file via `atomicWriteFileSync`, so an artifact
9
+ * the operator had already accepted / rejected / invalidated would be reset to a
10
+ * fresh `candidate` — erasing the operator's decision and re-breaking the loop
11
+ * that F2-INTEL-001 closed.
12
+ *
13
+ * Dedupe law (identical to the findings dedupe semantics):
14
+ * - id not on disk → write (new artifact)
15
+ * - id on disk, status candidate → write (refresh the machine output)
16
+ * - id on disk, NON-candidate status (reviewed/accepted/rejected/invalidated)
17
+ * → COLLISION, do NOT overwrite. The operator's
18
+ * decision is load-bearing and survives.
19
+ *
20
+ * The decision is intentionally the conservative one the findings layer already
21
+ * makes: preserve the operator's status by skipping the re-write entirely. The
22
+ * refreshed machine content is discarded for a promoted artifact rather than
23
+ * merged, because merging fresh `candidate`-shaped content into an `accepted`
24
+ * artifact would silently mutate what the operator signed off on.
25
+ */
26
+
27
+ /**
28
+ * @param {Array<object>} candidates - Freshly-derived artifact candidates.
29
+ * @param {Array<{ data: object }>} existing - Already-loaded on-disk artifacts
30
+ * (e.g. `loadPatternsWithSkips(rootDir).entries`).
31
+ * @param {string} idKey - The artifact's id field ('pattern_id' |
32
+ * 'recommendation_id' | 'doctrine_id').
33
+ * @returns {{ toWrite: Array<object>, skippedUnchanged: number, collisions: Array<{ id: string, existingStatus: string }> }}
34
+ */
35
+ export function dedupeArtifactsAgainstExisting(candidates, existing, idKey) {
36
+ const existingById = new Map();
37
+ for (const e of existing || []) {
38
+ const data = e?.data;
39
+ if (data && data[idKey]) existingById.set(data[idKey], data);
40
+ }
41
+
42
+ const toWrite = [];
43
+ let skippedUnchanged = 0;
44
+ const collisions = [];
45
+
46
+ for (const c of candidates) {
47
+ const id = c?.[idKey];
48
+ const prior = id != null ? existingById.get(id) : undefined;
49
+
50
+ if (!prior) {
51
+ toWrite.push(c);
52
+ continue;
53
+ }
54
+
55
+ // Same id exists — the operator's status is the gate.
56
+ if (prior.status !== 'candidate') {
57
+ // Promoted artifact: collision, never overwrite.
58
+ collisions.push({ id, existingStatus: prior.status });
59
+ continue;
60
+ }
61
+
62
+ // Still a candidate on disk — refresh the machine output.
63
+ toWrite.push(c);
64
+ }
65
+
66
+ return { toWrite, skippedUnchanged, collisions };
67
+ }
@@ -8,5 +8,8 @@ export { validatePattern, validateRecommendation, validateDoctrine } from './val
8
8
  export {
9
9
  writePattern, writeRecommendation, writeDoctrine,
10
10
  writePatterns, writeRecommendations, writeDoctrines,
11
- loadPatterns, loadRecommendations, loadDoctrines
11
+ loadPatterns, loadRecommendations, loadDoctrines,
12
+ loadPatternsWithSkips, loadRecommendationsWithSkips, loadDoctrinesWithSkips,
13
+ resetSeenArtifactWrites
12
14
  } from './write-artifacts.js';
15
+ export { dedupeArtifactsAgainstExisting } from './dedupe-artifacts.js';
@@ -12,9 +12,20 @@ import { loadFindings } from '../reader.js';
12
12
  /**
13
13
  * Derive candidate patterns from accepted findings.
14
14
  *
15
+ * D2B-001 — return shape carries a `skipped: [{ path, error }]` field so the
16
+ * silent-loader signal that the recommendation/doctrine derive stages already
17
+ * surface now also propagates through pattern derivation. `loadFindings`
18
+ * (reader.js) returns a torn / unparseable finding YAML as
19
+ * `{ valid: false, errors: [{ message }] }`; a torn file that on disk WAS an
20
+ * accepted finding would otherwise be filtered out of clustering with zero
21
+ * operator signal, quietly shrinking the evidence base behind a pattern's
22
+ * strength or threshold. The `skipped` list documents that partial-completion
23
+ * honestly. Legacy callers reading only `patterns` / `stats` are unaffected —
24
+ * the field is additive.
25
+ *
15
26
  * @param {string} rootDir - dogfood-labs repo root
16
27
  * @param {{ includeFixtures?: boolean }} opts
17
- * @returns {{ patterns: Array, stats: { findingsConsidered: number, clustersFound: number, belowThreshold: number } }}
28
+ * @returns {{ patterns: Array, skipped: Array<{path: string, error: string}>, stats: { findingsConsidered: number, clustersFound: number, belowThreshold: number, findingsSkipped: number } }}
18
29
  */
19
30
  export function derivePatterns(rootDir, opts = {}) {
20
31
  // Load only accepted, non-invalidated findings
@@ -23,6 +34,15 @@ export function derivePatterns(rootDir, opts = {}) {
23
34
  allFindings.push(...loadFindings(rootDir, { fixtures: true, fixtureKind: 'valid' }));
24
35
  }
25
36
 
37
+ // D2B-001 — partition torn / unreadable findings into a structured skip list
38
+ // BEFORE the accepted filter drops them. A `valid:false` record is a finding
39
+ // YAML that failed parse (reader.js sets errors[0].message to the parse error)
40
+ // or schema validation; its on-disk status is unknowable, so a torn file that
41
+ // WAS an accepted finding must not vanish silently from clustering.
42
+ const skipped = allFindings
43
+ .filter(f => f.valid === false)
44
+ .map(f => ({ path: f.path, error: f.errors?.[0]?.message || 'unknown read/parse error' }));
45
+
26
46
  const accepted = allFindings.filter(f =>
27
47
  f.valid &&
28
48
  f.data?.status === 'accepted' &&
@@ -68,10 +88,12 @@ export function derivePatterns(rootDir, opts = {}) {
68
88
 
69
89
  return {
70
90
  patterns,
91
+ skipped, // D2B-001: structured skip list for operator legibility
71
92
  stats: {
72
93
  findingsConsidered: accepted.length,
73
94
  clustersFound: clusters.size,
74
- belowThreshold
95
+ belowThreshold,
96
+ findingsSkipped: skipped.length
75
97
  }
76
98
  };
77
99
  }