@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 +54 -0
- package/cli.js +238 -17
- package/derive/ids.js +31 -3
- package/derive/write-findings.js +28 -0
- package/package.json +2 -2
- package/reader.js +74 -4
- package/review/event-log.js +72 -77
- package/review/review-artifacts.js +301 -0
- package/synthesis/apply-recommendation.js +259 -0
- package/synthesis/dedupe-artifacts.js +67 -0
- package/synthesis/index.js +4 -1
- package/synthesis/pattern-derivation.js +35 -4
- package/synthesis/write-artifacts.js +18 -0
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
|
-
|
|
672
|
-
|
|
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 &&
|
|
684
|
-
console.log(`(dry-run) ${
|
|
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
|
-
|
|
752
|
-
|
|
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 &&
|
|
764
|
-
console.log(`(dry-run) ${
|
|
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
|
-
|
|
828
|
-
|
|
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 &&
|
|
840
|
-
console.log(`(dry-run) ${
|
|
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
|
|
18
|
-
return
|
|
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
|
+
}
|
package/derive/write-findings.js
CHANGED
|
@@ -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
|
+
"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",
|