rcf-lite 0.28.0 → 0.28.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/CHANGELOG.md +115 -0
  2. package/README.md +9 -0
  3. package/fixtures/canary-manifest.json +9 -9
  4. package/package.json +1 -1
  5. package/rcf/code-nodes/cn-600.json +2 -1
  6. package/rcf/code-nodes/cn-602.json +4 -2
  7. package/rcf/code-nodes/cn-605.json +2 -1
  8. package/rcf/fbs/fbs-181.json +4 -2
  9. package/rcf/fbs/fbs-182.json +2 -1
  10. package/rcf/tacs/tac-4103-feedback-redact.json +3 -2
  11. package/rcf/tacs/tac-4106-feedback-destination.json +3 -2
  12. package/rcf/test-suites/ts-188.json +18 -2
  13. package/rcf/test-suites/ts-189.json +24 -2
  14. package/rcf/test-suites/ts-191.json +19 -5
  15. package/rcf/user-stories/us-15601.json +19 -1
  16. package/rcf/user-stories/us-15701.json +4 -4
  17. package/rcf/user-stories/us-15801.json +10 -1
  18. package/releases/releases.yaml +21 -1
  19. package/src/browser-verify/manifest-writer.js +9 -5
  20. package/src/cli/browser-verify.js +9 -1
  21. package/src/cli/feedback.js +201 -24
  22. package/src/cli/intake.js +25 -4
  23. package/src/cli/preflight.js +24 -3
  24. package/src/cli/review.js +9 -1
  25. package/src/cli/ui-baseline.js +14 -1
  26. package/src/core/store/index.js +5 -0
  27. package/src/core/store/validate-composed-record.js +95 -0
  28. package/src/feedback/destination.js +98 -4
  29. package/src/feedback/gh.js +27 -1
  30. package/src/feedback/redact-allowlist.json +7 -0
  31. package/src/feedback/redact.js +75 -6
  32. package/src/feedback/render.js +34 -12
  33. package/src/intake/manifest-writer.js +6 -3
  34. package/src/intake/orchestrator.js +44 -7
  35. package/src/preflight/manifest-writer.js +11 -11
  36. package/src/req-baseline/sweep.js +34 -5
  37. package/src/ui-baseline/manifest-writer.js +11 -10
  38. package/src/view/doc-renderers/helpers.js +37 -0
  39. package/src/view/doc-renderers/tad.js +67 -6
  40. package/src/view/style.css +5 -0
@@ -7,7 +7,14 @@
7
7
  //
8
8
  // Resolution order (design section 3.3):
9
9
  // 1. --kind core -> CORE_REPO (public)
10
- // 2. --kind blueprint, no libraryPrefix -> CORE_REPO (shelf; public)
10
+ // 2. --kind blueprint, no libraryPrefix -> library-slug inference
11
+ // (AC-15801-4 / design amendment R5a): if the applied slug is
12
+ // owned by exactly one registered library that resolves to a
13
+ // destination (issuesRepo or derivable sourceRef), route there
14
+ // with source: 'library-slug-inference'; if two or more libraries
15
+ // own the slug and resolve, return unresolved with reason
16
+ // 'ambiguous-library-slug' and a candidates[] array of prefixes;
17
+ // otherwise fall through to CORE_REPO (shelf; public).
11
18
  // 3. --kind blueprint, libraryPrefix -> registry entry issuesRepo
12
19
  // (declared destination wins;
13
20
  // visibility from the entry)
@@ -210,9 +217,10 @@ function recordEffectiveSlug(record) {
210
217
  * @property {'public' | 'private' | 'unresolved'} visibility
211
218
  * @property {'core' | 'shelf' | 'library'} kind
212
219
  * @property {boolean} derived true when repo came from sourceRef derivation
213
- * @property {string} [reason] filled on unresolved: 'no-issues-field' | 'no-github-source' | 'no-libraryprefix-record' | 'unknown-target'
220
+ * @property {string} [reason] filled on unresolved: 'no-issues-field' | 'no-github-source' | 'no-libraryprefix-record' | 'unknown-target' | 'ambiguous-library-slug'
221
+ * @property {string[]} [candidates] filled on unresolved with reason 'ambiguous-library-slug': sorted library prefixes owning the slug
214
222
  * @property {string} [publisherContact] registry entry's publisher.contact (bundle uses it)
215
- * @property {string} [source] 'library-manifest' | 'sourceRef-derivation' | 'package-bugs-url' | 'shelf-constant'
223
+ * @property {string} [source] 'library-manifest' | 'sourceRef-derivation' | 'package-bugs-url' | 'shelf-constant' | 'library-slug-inference'
216
224
  */
217
225
 
218
226
  /**
@@ -263,8 +271,27 @@ export async function resolve(entry, context = {}) {
263
271
 
264
272
  const libraryPrefix = record.libraryPrefix ?? entry?.target?.libraryPrefix ?? null;
265
273
 
266
- // Shelf blueprint (no libraryPrefix) -> Stravica/rcf-lite.
274
+ // Shelf blueprint (no libraryPrefix on the manifest record). Before
275
+ // falling through to the shelf constant, cross-check the registry:
276
+ // if exactly one registered library owns a blueprint with the applied
277
+ // slug AND that library resolves to a destination, route to that
278
+ // library so a bare-slug apply of a library-shared name still lands
279
+ // on the library owner's repo (design amendment R5, AC-15801-4).
267
280
  if (!libraryPrefix) {
281
+ const inferred = inferLibraryFromSlug(record, registry);
282
+ if (inferred.kind === 'single') {
283
+ return inferred.destination;
284
+ }
285
+ if (inferred.kind === 'ambiguous') {
286
+ return {
287
+ repo: null,
288
+ visibility: 'unresolved',
289
+ kind: 'library',
290
+ derived: false,
291
+ reason: 'ambiguous-library-slug',
292
+ candidates: inferred.candidates,
293
+ };
294
+ }
268
295
  return {
269
296
  repo: CORE_REPO,
270
297
  visibility: 'public',
@@ -359,3 +386,70 @@ function findLibraryEntry(registry, libraryPrefix) {
359
386
  const libs = Array.isArray(registry?.libraries) ? registry.libraries : [];
360
387
  return libs.find((l) => l?.libraryPrefix === libraryPrefix) ?? null;
361
388
  }
389
+
390
+ /**
391
+ * AC-15801-4 / design amendment R5a: given a manifest record with no
392
+ * libraryPrefix, cross-check the registry to see whether the applied
393
+ * slug is owned by exactly one registered library that resolves to a
394
+ * destination. Returns:
395
+ * { kind: 'none' } no library owns the slug (or none resolve)
396
+ * { kind: 'single', destination } exactly one library owns the slug and resolves
397
+ * { kind: 'ambiguous', candidates: string[] } two or more libraries own the slug and resolve
398
+ *
399
+ * The resolver only calls this on the shelf-constant fall-through
400
+ * branch, so it never overrides an existing libraryPrefix resolution.
401
+ *
402
+ * @param {object} record manifest.blueprints[] record
403
+ * @param {object | null | undefined} registry parsed rcf/blueprint-libraries.json
404
+ */
405
+ function inferLibraryFromSlug(record, registry) {
406
+ const slug = typeof record?.slug === 'string' ? record.slug : null;
407
+ if (!slug) return { kind: 'none' };
408
+ const libs = Array.isArray(registry?.libraries) ? registry.libraries : [];
409
+ const owners = [];
410
+ for (const lib of libs) {
411
+ if (!lib || typeof lib !== 'object') continue;
412
+ if (typeof lib.libraryPrefix !== 'string' || lib.libraryPrefix.length === 0) continue;
413
+ const bps = Array.isArray(lib.blueprints) ? lib.blueprints : [];
414
+ const owns = bps.some((b) => b && typeof b.slug === 'string' && b.slug === slug);
415
+ if (!owns) continue;
416
+ const declared = typeof lib.issuesRepo === 'string' && lib.issuesRepo.length > 0 ? lib.issuesRepo : null;
417
+ const derivedRepo = declared ? null : parseGithubSource(lib.sourceRef ?? '');
418
+ if (!declared && !derivedRepo) continue;
419
+ const contact = typeof lib?.publisher?.contact === 'string' ? lib.publisher.contact : undefined;
420
+ owners.push({ lib, declared, derivedRepo, contact });
421
+ }
422
+ if (owners.length === 0) return { kind: 'none' };
423
+ if (owners.length === 1) {
424
+ const [o] = owners;
425
+ if (o.declared) {
426
+ const declaredVisibility = o.lib.issuesVisibility === 'private' ? 'private'
427
+ : o.lib.issuesVisibility === 'public' ? 'public'
428
+ : 'public';
429
+ return {
430
+ kind: 'single',
431
+ destination: {
432
+ repo: o.declared,
433
+ visibility: declaredVisibility,
434
+ kind: 'library',
435
+ derived: false,
436
+ source: 'library-slug-inference',
437
+ ...(o.contact ? { publisherContact: o.contact } : {}),
438
+ },
439
+ };
440
+ }
441
+ return {
442
+ kind: 'single',
443
+ destination: {
444
+ repo: o.derivedRepo,
445
+ visibility: 'public',
446
+ kind: 'library',
447
+ derived: true,
448
+ source: 'library-slug-inference',
449
+ ...(o.contact ? { publisherContact: o.contact } : {}),
450
+ },
451
+ };
452
+ }
453
+ const candidates = owners.map((o) => o.lib.libraryPrefix).sort();
454
+ return { kind: 'ambiguous', candidates };
455
+ }
@@ -5,8 +5,9 @@
5
5
  // - ghLabelList({ repo }) list existing label names
6
6
  // - ghRepoView({ repo }) visibility + viewerPermission + hasIssuesEnabled
7
7
  // - ghIssueSearch({ repo, query, state, limit }) dedupe search
8
+ // - ghIssueGetBody({ repo, number }) re-read a candidate body for fingerprint verification (0.28.2 / #234)
8
9
  // - ghIssueCreate({ repo, title, body, labels }) create one issue
9
- // - ghIssueComment({ repo, number, body }) +1 comment
10
+ // - ghIssueComment({ repo, number, body }) fold comment (carries the full report; the "+1 from another reporter" payload retired in 0.28.2 per Barry's 2026-09-21 ruling on #234)
10
11
  // - ghLabelCreate({ repo, name, description, color }) bootstrap only
11
12
  //
12
13
  // Every function returns a typed result. Errors are classified into
@@ -68,6 +69,7 @@ export async function loadGhAdapter(env = process.env) {
68
69
  * @property {(opts: { repo: string }) => Promise<GhResult>} ghLabelList
69
70
  * @property {(opts: { repo: string }) => Promise<GhResult>} ghRepoView
70
71
  * @property {(opts: { repo: string, query: string, state?: 'open' | 'closed', limit?: number }) => Promise<GhResult>} ghIssueSearch
72
+ * @property {(opts: { repo: string, number: number }) => Promise<GhResult>} ghIssueGetBody
71
73
  * @property {(opts: { repo: string, title: string, body: string, labels: string[] }) => Promise<GhResult>} ghIssueCreate
72
74
  * @property {(opts: { repo: string, number: number, body: string }) => Promise<GhResult>} ghIssueComment
73
75
  * @property {(opts: { repo: string, name: string, description?: string, color?: string }) => Promise<GhResult>} ghLabelCreate
@@ -189,6 +191,29 @@ export async function ghIssueSearch(opts) {
189
191
  }
190
192
  }
191
193
 
194
+ /**
195
+ * `gh issue view <n> --repo <repo> --json body`. Re-reads the body of
196
+ * a candidate issue for the fingerprint-verification step introduced
197
+ * in 0.28.2 (issue #234): a search hit whose body does not carry the
198
+ * fingerprint line on its own line is a non-match and is discarded
199
+ * before the fold decision. Returns { body }.
200
+ *
201
+ * @param {{ repo: string, number: number }} opts
202
+ * @returns {Promise<GhResult>}
203
+ */
204
+ export async function ghIssueGetBody(opts) {
205
+ const args = ['issue', 'view', String(opts.number), '--repo', opts.repo, '--json', 'body'];
206
+ const r = await runGh(args);
207
+ if (!r.ok) return r;
208
+ try {
209
+ const parsed = JSON.parse(r.value.stdout);
210
+ const body = typeof parsed?.body === 'string' ? parsed.body : '';
211
+ return { ok: true, value: { body } };
212
+ } catch (err) {
213
+ return { ok: false, kind: 'unknown', message: `issue-view parse failed: ${err.message}` };
214
+ }
215
+ }
216
+
192
217
  /**
193
218
  * `gh issue create --repo <repo> --title <t> --body-file - --label
194
219
  * <l1> --label <l2>...` reads the body from stdin so a long body is
@@ -370,6 +395,7 @@ export const DEFAULT_ADAPTER = Object.freeze({
370
395
  ghLabelList,
371
396
  ghRepoView,
372
397
  ghIssueSearch,
398
+ ghIssueGetBody,
373
399
  ghIssueCreate,
374
400
  ghIssueComment,
375
401
  ghLabelCreate,
@@ -10,5 +10,12 @@
10
10
  "developer.mozilla.org",
11
11
  "localhost",
12
12
  "127.0.0.1"
13
+ ],
14
+ "extensions": [
15
+ "md", "mdx", "json", "yml", "yaml", "txt",
16
+ "js", "mjs", "cjs", "ts", "tsx",
17
+ "html", "css",
18
+ "png", "svg",
19
+ "sh", "csv", "log", "lock"
13
20
  ]
14
21
  }
@@ -32,6 +32,26 @@ const BUNDLED_ALLOWLIST = JSON.parse(
32
32
  /** Body size cap (bytes). Mirrors src/cli/feedback.js:BODY_CAP_BYTES. */
33
33
  export const BODY_CAP_BYTES = 8 * 1024;
34
34
 
35
+ /**
36
+ * Common-noun stopwords the operator-identity substitution never folds
37
+ * (AC-15601-5 / design amendment R5c). The rcf-lite ecosystem uses
38
+ * `agent` as a canonical noun (RULE 17, docs paths such as
39
+ * `how-the-agent-files-feedback`); if the operator's identity name
40
+ * happens to contain one of these words, the token loop skips it so
41
+ * the docs URL and prose like `when the agent stops` survive. Real
42
+ * given names in the identity still fold. The list is intentionally
43
+ * conservative: anything a normal profile.md ## Name line might carry
44
+ * as a common English or engineering noun.
45
+ */
46
+ export const RESERVED_IDENTITY_TOKENS = Object.freeze(new Set([
47
+ 'agent', 'agents', 'user', 'users', 'operator', 'operators',
48
+ 'admin', 'admins', 'root', 'client', 'clients', 'server', 'servers',
49
+ 'service', 'services', 'system', 'systems', 'name', 'main', 'test',
50
+ 'code', 'api', 'apis', 'anon', 'anonymous', 'human', 'humans',
51
+ 'dev', 'ops', 'sre', 'docs', 'developer', 'developers', 'engineer',
52
+ 'engineers', 'team', 'teams',
53
+ ]));
54
+
35
55
  /**
36
56
  * Open secret-key vocabulary (design amendment R3a). A key/value pair
37
57
  * is treated as a secret when the key, right-bounded at a word edge so
@@ -389,9 +409,20 @@ export function redact(input, context = {}) {
389
409
  if (context.operatorName) {
390
410
  const opName = context.operatorName.trim();
391
411
  if (opName.length > 0) {
392
- text = replaceAndLog(text, escapedLiteral(opName), '<operator>', 'operator-identity', ledger);
393
- for (const tok of opName.split(/\s+/)) {
394
- if (tok.length > 3) {
412
+ // AC-15601-5 / design amendment R5c: never fold a common noun
413
+ // that happens to appear in the identity string. The rcf-lite
414
+ // ecosystem uses 'agent' as a canonical noun (RULE 17, docs
415
+ // paths); folding it as operator-identity mangles the docs URL
416
+ // `how-the-agent-files-feedback` and prose like `when the
417
+ // agent stops`. Substitution still fires for a real given name
418
+ // (Alice, Smith) even when the full identity string carries a
419
+ // stopword. When the identity contains ONLY stopwords (a
420
+ // placeholder-in-disguise), skip identity folds entirely.
421
+ const rawTokens = opName.split(/\s+/);
422
+ const foldable = rawTokens.filter((t) => t.length > 3 && !RESERVED_IDENTITY_TOKENS.has(t.toLowerCase()));
423
+ if (foldable.length > 0) {
424
+ text = replaceAndLog(text, escapedLiteral(opName), '<operator>', 'operator-identity', ledger);
425
+ for (const tok of foldable) {
395
426
  text = replaceAndLog(text, `\\b${escapeRe(tok)}\\b`, '<operator>', 'operator-identity', ledger);
396
427
  }
397
428
  }
@@ -399,7 +430,7 @@ export function redact(input, context = {}) {
399
430
  }
400
431
  if (context.projectName) {
401
432
  const pn = context.projectName.trim();
402
- if (pn.length > 0) {
433
+ if (pn.length > 0 && !RESERVED_IDENTITY_TOKENS.has(pn.toLowerCase())) {
403
434
  text = replaceAndLog(text, `\\b${escapeRe(pn)}\\b`, '<project>', 'operator-identity', ledger);
404
435
  }
405
436
  }
@@ -417,6 +448,18 @@ export function redact(input, context = {}) {
417
448
  ...(Array.isArray(context.allowHosts) ? context.allowHosts : []),
418
449
  ].map((h) => h.toLowerCase()));
419
450
 
451
+ // Rule 4 narrowing (0.28.2, issue #233, design amendment R5d): a
452
+ // token whose trailing dot-label is a known file extension is NOT
453
+ // a hostname. This kills the `Backstory-product-brief.md` and
454
+ // `intake.json` false positives Barry hit in the WSD round-trip.
455
+ // The bundled shortlist lives in redact-allowlist.json under the
456
+ // new `extensions` field; the operator overlay merges via
457
+ // context.allowExtensions.
458
+ const allowExtensions = new Set([
459
+ ...(Array.isArray(BUNDLED_ALLOWLIST.extensions) ? BUNDLED_ALLOWLIST.extensions : []),
460
+ ...(Array.isArray(context.allowExtensions) ? context.allowExtensions : []),
461
+ ].map((e) => String(e).toLowerCase()));
462
+
420
463
  // Rule 9: URL-embedded credentials (design amendment R3b, added
421
464
  // round 4 for F-P0-B). Runs BEFORE the rule-4 hostname pass so a
422
465
  // Slack/Discord webhook URL whose path IS the credential has that
@@ -439,9 +482,26 @@ export function redact(input, context = {}) {
439
482
  // preceded by @ (already handled by rule 3), a slash or backslash
440
483
  // (path fragment; the winAbsRe substitution leaves `<path>\file.ext`
441
484
  // which must not fold to `<host>`), a digit-dot (IP handled by
442
- // rule 5b), or another word character (a longer host suffix).
443
- const bareHostRe = /(?<![@/\\\w.])(?:[A-Za-z0-9-]+\.)+[A-Za-z]{2,}(?![\w.-])/g;
485
+ // rule 5b), a hyphen or another word character (a longer host
486
+ // suffix or a filename-internal segment). Adding `-` to the
487
+ // lookbehind is the 0.28.2 (issue #233) fix that stops
488
+ // `Backstory-product-brief.md` folding on its internal
489
+ // `product-brief.md` slice; the extension-refusal step below is the
490
+ // second layer.
491
+ const bareHostRe = /(?<![@/\\\w.\-])(?:[A-Za-z0-9-]+\.)+[A-Za-z]{2,}(?![\w.-])/g;
444
492
  text = replaceRegex(text, bareHostRe, (host) => {
493
+ // 0.28.2 (issue #233, design amendment R5d): a candidate whose
494
+ // trailing dot-label is a known file extension is not a hostname;
495
+ // leave it intact and record no ledger row (we are not redacting,
496
+ // we are correctly leaving alone). This narrowing catches shapes
497
+ // like `report.log`, `intake.json` and `Backstory-example.md`
498
+ // that pass the lookbehind guard because they start after a
499
+ // whitespace or line boundary.
500
+ const lastDot = host.lastIndexOf('.');
501
+ if (lastDot > 0) {
502
+ const trailingLabel = host.slice(lastDot + 1).toLowerCase();
503
+ if (allowExtensions.has(trailingLabel)) return host;
504
+ }
445
505
  if (isHostAllowed(host, allowHosts)) return host;
446
506
  return '<host>';
447
507
  }, 'hostname', ledger);
@@ -846,6 +906,15 @@ function looksHighEntropyPath(value) {
846
906
  if (typeof value !== 'string') return false;
847
907
  if (value.length < 16) return false;
848
908
  if (!/^[A-Za-z0-9._~+/=-]+$/.test(value)) return false;
909
+ // AC-15601-5 canon-phrase guard: a lowercase kebab-case segment with
910
+ // three or more hyphen-separated word parts is a documentation slug,
911
+ // not a credential. The rcf-lite docs URL
912
+ // `how-the-agent-files-feedback` is 28 chars, 2 classes (lowercase
913
+ // and separator), and would otherwise trip the 16-char, 2-class rule
914
+ // and produce `<url-credential>` inside the docs URL. Real credential
915
+ // segments in URL paths (webhook tokens, session ids, presigned
916
+ // credentials) are mixed-case-alphanumeric, not kebab-case English.
917
+ if (/^[a-z]+(-[a-z]+){2,}$/.test(value)) return false;
849
918
  let classes = 0;
850
919
  if (/[A-Z]/.test(value)) classes += 1;
851
920
  if (/[a-z]/.test(value)) classes += 1;
@@ -110,27 +110,39 @@ function truncateToBytes(text, maxBytes) {
110
110
  }
111
111
 
112
112
  /**
113
- * Render the `+1 from another reporter` comment body used on the
114
- * dedupe path (slice 4). Kept in this module because triage sees one
115
- * shape from `create` and `comment`.
113
+ * Render the fold comment body used on the dedupe path (slice 4).
114
+ * 0.28.2 (issue #234, Barry ruling 2026-09-21): the retired
115
+ * "+1 from another reporter." payload is gone; the comment always
116
+ * carries the full report so a human can judge whether the fold is
117
+ * correct. The header line names the fold decision. The visible
118
+ * fingerprint line at the bottom stays so a later submit can still
119
+ * match on it.
116
120
  *
117
121
  * @param {object} entry
118
- * @param {{ body: string, ledger: Array<{ rule: string, count: number }> }} redacted
119
- * @param {{ fingerprint: string, includeBody?: boolean }} meta
122
+ * @param {{ title: string, body: string, evidence: Array<{ kind: string, value: string }>, ledger: Array<{ rule: string, count: number }> }} redacted
123
+ * @param {{ fingerprint: string, matchedTitle?: string }} meta
120
124
  * @returns {RenderedComment}
121
125
  */
122
126
  export function renderComment(entry, redacted, meta) {
123
127
  const lines = [];
124
- lines.push('+1 from another reporter.');
128
+ lines.push('Apparent duplicate of the issue subject; posting the full report so a human can judge.');
129
+ lines.push('');
130
+ lines.push(`## Report: ${renderTitle(entry, redacted.title)}`);
131
+ lines.push('');
132
+ lines.push(redacted.body);
125
133
  lines.push('');
126
- lines.push(...renderEnvironmentTable(entry).split('\n'));
127
- if (meta.includeBody) {
128
- lines.push('');
129
- lines.push('Report body:');
130
- lines.push('');
131
- lines.push(redacted.body);
134
+ lines.push('---');
135
+ lines.push('**Evidence**');
136
+ if (Array.isArray(redacted.evidence) && redacted.evidence.length > 0) {
137
+ for (const ev of redacted.evidence) {
138
+ lines.push(`- \`${ev.value}\` (${ev.kind})`);
139
+ }
140
+ } else {
141
+ lines.push('- (none)');
132
142
  }
133
143
  lines.push('');
144
+ lines.push(renderEnvironmentTable(entry));
145
+ lines.push('');
134
146
  lines.push(`rcf-feedback-fingerprint: ${meta.fingerprint}`);
135
147
  return { body: `${lines.join('\n')}\n` };
136
148
  }
@@ -241,10 +253,20 @@ function renderEnvironmentTable(entry) {
241
253
  t.libraryPrefix ? `(library ${t.libraryPrefix}${t.libraryRef ? ` ${t.libraryRef}` : ''}${t.resolvedSha ? `, git ${String(t.resolvedSha).slice(0, 7)}` : t.tarballSha256 ? `, tarball ${String(t.tarballSha256).slice(0, 7)}` : ''})` : null,
242
254
  ].filter(Boolean).join(' ')
243
255
  : 'n/a';
256
+ // 0.28.2 (issue #234): a "target" row goes on the environment
257
+ // table for core entries too, so submit's fingerprint-verification
258
+ // step can compare the entry's verb path against a candidate's
259
+ // recorded target and refuse to fold across differing targets. For
260
+ // blueprint entries the blueprint cell already carries the target
261
+ // identity, so the target row is left as "-" to avoid duplication.
262
+ const targetCell = kind === 'blueprint'
263
+ ? '-'
264
+ : (typeof t.ref === 'string' && t.ref.length > 0 ? t.ref : '-');
244
265
  const rows = [
245
266
  ['field', 'value'],
246
267
  ['kind', kind],
247
268
  ['blueprint', blueprintCell],
269
+ ['target', targetCell],
248
270
  ['anchor', entry?.anchor ?? '-'],
249
271
  ['symptom class', entry?.symptomClass ?? '-'],
250
272
  ['severity', entry?.severity ?? '-'],
@@ -9,7 +9,7 @@ import { mkdir, rename, unlink, writeFile } from 'node:fs/promises';
9
9
  import { dirname, join } from 'node:path';
10
10
 
11
11
  import { rcfError } from '#core/errors';
12
- import { validateDocument } from '#core/store';
12
+ import { buildNextManifest, validateDocument } from '#core/store';
13
13
 
14
14
  const ID_RE = /^ic-\d{4}-\d{2}-\d{2}-(\d{3})$/;
15
15
 
@@ -73,8 +73,11 @@ export function composeIntakeRecord({ manifest, fidelity, artefacts, validationF
73
73
  * @returns {Promise<{ record: object } | import('#core/errors').RcfError>}
74
74
  */
75
75
  export async function writeIntakeRecord({ projectRoot, tree, record }) {
76
- const manifest = tree?.manifest ?? {};
77
- const nextManifest = { ...manifest, intakeClassification: record };
76
+ // 0.28.2 (Codex review follow-up on #230): the writer synthesises
77
+ // the next manifest via the same `buildNextManifest` helper the
78
+ // dry-run branch uses, so preview and write share one composition
79
+ // path. `validateDocument` then runs on the identical shape.
80
+ const nextManifest = buildNextManifest({ manifest: tree?.manifest ?? {}, record, verb: 'intake' });
78
81
  const validation = validateDocument({ doc: nextManifest, kind: 'manifest', filePath: 'rcf/manifest.json' });
79
82
  if (validation) return validation;
80
83
  const abs = join(projectRoot, 'rcf', 'manifest.json');
@@ -82,23 +82,60 @@ export async function runIntakePhases({ projectRoot, artefactPaths, kindHint = n
82
82
 
83
83
  // Fold non-interactive input: extra findings, operator responses,
84
84
  // and elicitationScope hints all land here.
85
+ //
86
+ // 0.28.2 (issue #229): findings are material-scoped, not
87
+ // artefact-scoped. Two artefacts that both trip the same branch
88
+ // ("web UI but no sign-in surface named") emit the same finding
89
+ // twice with byte-identical detail text, and downstream that
90
+ // inflated `findings=2` on a record with one finding. Dedupe by
91
+ // `${kind}::${detail}` (first-wins) before timestamping so the
92
+ // material carries one finding, not N.
93
+ //
94
+ // Same commit fixes the second half of #229: operator-supplied
95
+ // `validationFindings` on --input were dropped unless the kind was
96
+ // `otherDeclared`. Fold every input finding into the same map so
97
+ // an operator-authored `impliedButNotStated`, `contradiction` or
98
+ // `missingLoadBearingConstraint` survives the round-trip and
99
+ // coalesces with a scanned duplicate (the operator's response is
100
+ // preserved when it does).
85
101
  const inputFindings = Array.isArray(input?.validationFindings) ? input.validationFindings : [];
102
+
103
+ /** @type {Map<string, object>} */
104
+ const foldedFindings = new Map();
105
+ const foldKey = (f) => `${String(f.kind ?? '')}::${String(f.detail ?? '')}`;
106
+ for (const f of allFindings) {
107
+ const k = foldKey(f);
108
+ if (!foldedFindings.has(k)) foldedFindings.set(k, f);
109
+ }
110
+ for (const f of inputFindings) {
111
+ if (typeof f?.kind !== 'string' || typeof f?.detail !== 'string') continue;
112
+ const k = foldKey(f);
113
+ if (!foldedFindings.has(k)) foldedFindings.set(k, f);
114
+ }
115
+
116
+ // 0.28.2 (Codex review follow-up on #229): the response lookup is
117
+ // keyed by (kind, detail) too, so resolving one finding never
118
+ // silently marks a different-kind finding with the same detail as
119
+ // resolved. Previously the lookup was `detail` only and an
120
+ // answered `impliedButNotStated` leaked its response onto an
121
+ // unanswered `contradiction` with the same detail string.
86
122
  const inputResponses = new Map();
87
123
  for (const f of inputFindings) {
88
- if (typeof f?.detail === 'string' && typeof f?.operatorResponse === 'string') {
89
- inputResponses.set(f.detail, f.operatorResponse);
124
+ if (typeof f?.kind === 'string'
125
+ && typeof f?.detail === 'string'
126
+ && typeof f?.operatorResponse === 'string') {
127
+ inputResponses.set(foldKey(f), f.operatorResponse);
90
128
  }
91
- if (f?.kind === 'otherDeclared') allFindings.push(f);
92
129
  }
93
130
 
94
131
  const isoNow = now.toISOString();
95
- const findingsWithTimestamps = allFindings.map((f) => {
96
- const entry = { kind: f.kind, detail: f.detail, raisedAt: isoNow };
132
+ const findingsWithTimestamps = Array.from(foldedFindings.values()).map((f) => {
133
+ const entry = { kind: f.kind, detail: f.detail, raisedAt: f.raisedAt ?? isoNow };
97
134
  if (f.kindDescription) entry.kindDescription = f.kindDescription;
98
- const response = inputResponses.get(f.detail) ?? input?.operatorResponse;
135
+ const response = f.operatorResponse ?? inputResponses.get(foldKey(f)) ?? input?.operatorResponse;
99
136
  if (typeof response === 'string' && response.length > 0) {
100
137
  entry.operatorResponse = response;
101
- entry.resolvedAt = isoNow;
138
+ entry.resolvedAt = f.resolvedAt ?? isoNow;
102
139
  }
103
140
  return entry;
104
141
  });
@@ -19,7 +19,7 @@ import { mkdir, rename, unlink, writeFile } from 'node:fs/promises';
19
19
  import { dirname, join } from 'node:path';
20
20
 
21
21
  import { rcfError } from '#core/errors';
22
- import { validateDocument } from '#core/store';
22
+ import { buildNextManifest, validateDocument } from '#core/store';
23
23
 
24
24
  /**
25
25
  * @typedef {object} PreflightServiceRuling
@@ -210,16 +210,16 @@ export function composeOptOutRecord({
210
210
  export async function writePreflightRecord({
211
211
  projectRoot, tree, record, optOuts = [], options = {},
212
212
  }) {
213
- const manifest = tree.manifest ?? {};
214
- const nextManifest = { ...manifest };
215
-
216
- const existingPfc = Array.isArray(nextManifest.preFlightConfig) ? nextManifest.preFlightConfig : [];
217
- nextManifest.preFlightConfig = [...existingPfc, record];
218
-
219
- if (optOuts.length > 0) {
220
- const existingOptOuts = Array.isArray(nextManifest.baselineAcOptOuts) ? nextManifest.baselineAcOptOuts : [];
221
- nextManifest.baselineAcOptOuts = [...existingOptOuts, ...optOuts];
222
- }
213
+ // 0.28.2 (Codex review follow-up on #232): compose the next
214
+ // manifest via the shared `buildNextManifest` helper so the write
215
+ // and dry-run branches produce byte-identical shapes; validation
216
+ // then runs on the same result.
217
+ const nextManifest = buildNextManifest({
218
+ manifest: tree.manifest ?? {},
219
+ record,
220
+ verb: 'preflight',
221
+ extraRecords: optOuts,
222
+ });
223
223
 
224
224
  const relPath = 'rcf/manifest.json';
225
225
  const validation = validateDocument({ doc: nextManifest, kind: 'manifest', filePath: relPath });
@@ -87,10 +87,20 @@ export async function applySweepDecisions({ projectRoot, tree, decisions, now =
87
87
  const usDoc = tree.byId.get(usId);
88
88
  if (!usDoc) continue;
89
89
 
90
+ // 0.28.2 (issue #231): a per-US allocator seeded ONCE from the
91
+ // current maxN on `usDoc.acceptanceCriteria`, then handed to each
92
+ // `composeBaselineAc` for the run. `nextAcId` was stateless
93
+ // across iterations, so every candidate accepted for one US
94
+ // received the identical `AC-<usNum>-<maxN+1>` and the writer's
95
+ // post-write validation refused the tree with a duplicate-id
96
+ // breakage. Ordinal allocation is monotone within a batch and
97
+ // across batches (a later sweep on the same US resumes from the
98
+ // new maxN in the tree).
99
+ const allocator = makeAcIdAllocator(usDoc);
90
100
  const acsToAppend = [];
91
101
  for (const d of list) {
92
102
  if (d.action === 'accept') {
93
- const ac = composeBaselineAc({ usDoc, candidate: d.candidate, now });
103
+ const ac = composeBaselineAc({ usDoc, candidate: d.candidate, now, idOverride: allocator() });
94
104
  acsToAppend.push(ac);
95
105
  writtenAcIds.push(ac.id);
96
106
  accepted += 1;
@@ -172,9 +182,9 @@ export async function applyPendingBaselinesForUs({ tree, usId }) {
172
182
  * @param {Date} args.now
173
183
  * @returns {object}
174
184
  */
175
- export function composeBaselineAc({ usDoc, candidate, now }) {
185
+ export function composeBaselineAc({ usDoc, candidate, now, idOverride = null }) {
176
186
  const isoNow = now.toISOString();
177
- const id = nextAcId(usDoc);
187
+ const id = typeof idOverride === 'string' && idOverride.length > 0 ? idOverride : nextAcId(usDoc);
178
188
  const canonicalText = candidate.canonicalText;
179
189
  const gwt = candidate.given || candidate.when || candidate.then
180
190
  ? { given: candidate.given ?? '', when: candidate.when ?? '', then: candidate.then ?? '' }
@@ -200,8 +210,22 @@ export function composeBaselineAc({ usDoc, candidate, now }) {
200
210
  const AC_HIER_ID_RE = /^AC-(\d{3,})-(\d+)$/;
201
211
 
202
212
  function nextAcId(usDoc) {
213
+ const alloc = makeAcIdAllocator(usDoc);
214
+ return alloc();
215
+ }
216
+
217
+ /**
218
+ * 0.28.2 (issue #231): return a monotone AC-id allocator seeded once
219
+ * from `usDoc.acceptanceCriteria`. Every subsequent call returns
220
+ * `AC-<usNum>-<n++>`; a caller that accepts N baseline candidates for
221
+ * one US in one sweep now gets N distinct ids, and the writer's
222
+ * duplicate-id refusal on `updateDocument` no longer bites.
223
+ *
224
+ * @param {object} usDoc
225
+ * @returns {() => string}
226
+ */
227
+ export function makeAcIdAllocator(usDoc) {
203
228
  const acs = Array.isArray(usDoc.acceptanceCriteria) ? usDoc.acceptanceCriteria : [];
204
- // Prefer hierarchical form matching the US id, e.g. US-201 → AC-201-1.
205
229
  const usNum = (usDoc.usId ?? '').match(/^US-(\d+)$/)?.[1];
206
230
  let maxN = 0;
207
231
  for (const ac of acs) {
@@ -212,7 +236,12 @@ function nextAcId(usDoc) {
212
236
  const n = Number.parseInt(tail, 10);
213
237
  if (Number.isFinite(n) && n > maxN) maxN = n;
214
238
  }
215
- return usNum ? `AC-${usNum}-${maxN + 1}` : `AC-${(Math.floor(Math.random() * 999)).toString().padStart(3, '0')}-1`;
239
+ let counter = maxN;
240
+ return () => {
241
+ counter += 1;
242
+ if (!usNum) return `AC-${(Math.floor(Math.random() * 999)).toString().padStart(3, '0')}-${counter}`;
243
+ return `AC-${usNum}-${counter}`;
244
+ };
216
245
  }
217
246
 
218
247
  function extractGwt(text) {
@@ -18,7 +18,7 @@ import { mkdir, rename, unlink, writeFile } from 'node:fs/promises';
18
18
  import { dirname, join } from 'node:path';
19
19
 
20
20
  import { rcfError } from '#core/errors';
21
- import { validateDocument } from '#core/store';
21
+ import { buildNextManifest, validateDocument } from '#core/store';
22
22
 
23
23
  import { composeDefaults, deepGet, deepSet } from './defaults.js';
24
24
  import { CATALOGUE_V1 } from '../preflight/design-shapes.js';
@@ -150,15 +150,16 @@ export function preflightSeamOverrides(manifest, catalogue = CATALOGUE_V1) {
150
150
  export async function writeUiBaselineRecord({
151
151
  projectRoot, tree, record, options = {},
152
152
  }) {
153
- const manifest = tree.manifest ?? {};
154
- const nextManifest = { ...manifest };
155
- const priorBaseline = nextManifest.uiBaseline ?? null;
156
-
157
- if (priorBaseline && options.reset) {
158
- const history = Array.isArray(nextManifest.uiBaselineHistory) ? nextManifest.uiBaselineHistory : [];
159
- nextManifest.uiBaselineHistory = [...history, priorBaseline];
160
- }
161
- nextManifest.uiBaseline = record;
153
+ // 0.28.2 (Codex review follow-up on the shared helper): compose via
154
+ // the shared `buildNextManifest` helper so write and dry-run
155
+ // produce the same shape. `options.reset` still drives the
156
+ // history-move branch inside the helper.
157
+ const nextManifest = buildNextManifest({
158
+ manifest: tree.manifest ?? {},
159
+ record,
160
+ verb: 'uiBaselineInit',
161
+ options: { reset: Boolean(options.reset) },
162
+ });
162
163
 
163
164
  const relPath = 'rcf/manifest.json';
164
165
  const validation = validateDocument({ doc: nextManifest, kind: 'manifest', filePath: relPath });