feat(#108): memory distillation pipeline — scraper, filter, distiller, CLI, and CI workflow#109
Conversation
- Create agent-memory/_pending/<domain>/ for all 8 CHT domains - Add empty agent-memory/_skipped.ndjson for filter audit log - Define pipeline types in src/types/pipeline.ts: ScrapedPR, LinkedIssue, ReviewComment, FilterDecision, SkipLogEntry, ScraperError
Synchronous scraper that collects structured data from medic/cht-core PRs for the memory distillation pipeline. - Fetches PR metadata, diff, review summaries, and linked issues - Uses execFileSync with array args (no shell injection risk) - 50 MB maxBuffer with ENOBUFS → ScraperError - Graceful fallback for org-membership (read:org not required) - Linked issues extracted via regex; GitHub sidebar links documented as a known limitation (requires GraphQL) - PENDING reviews filtered out - 22 unit tests, all coverage thresholds met
- Add 10 optional frontmatter fields to schema.json: source_pr, source_sha, distilled_at, reviewed_by, reviewed_at, confidence, entities, concepts, related_issues, stale - All new fields are optional (backward-compatible with existing entries) - Update TEMPLATE.md with commented stubs and field reference table - Add src/scripts/validate-schema.ts (gray-matter + ajv) to validate all agent-memory /**/*.md frontmatter; 30/30 existing entries pass - Add npm run validate-schema script - Fix YAML syntax error in 10729-smsparser-bugs-typos.md (unquoted colon)
Bare keyword regex (e.g. "Fixes #123") missed the URL form used by some cht-core PRs ("Fixes https://github.com/…/issues/123"). Updated pattern captures both forms and deduplicates across them. Added 2 tests: URL extraction and bare+URL dedup.
Three-stage pipeline filter for ScrapedPR objects: - Stage 1: 5 deterministic skip rules (bot, revert, chore/docs/ci/build, lockfile-only, translation-only) - Stage 2: 3 deterministic distill rules (bug+issue+multiservice, feature+issue, shared-libs+multiconsumer) - Stage 3: LLM triage via ChatAnthropic with Zod structured output; graceful fallback to flag-for-human on failure or missing API key Also adds ScrapedPR.author field, FilterResult/FilterOptions types, and _skipped.ndjson audit log (append-only, skip/flag decisions only). DoD items verified: - All 5 skip rules + all 3 distill rules implemented and tested - touchesMultipleServices edge cases covered - LLM failure -> flag-for-human, no throw - skipLlm option bypasses LLM call - 211 tests passing, branches 89.24%, lines 99.29%
…triage Switch LLM triage from hardcoded ChatAnthropic to a dual-provider setup: - OpenRouter (OPENROUTER_API_KEY) as primary via ChatOpenAI - Anthropic direct (ANTHROPIC_API_KEY) as fallback - Graceful flag-for-human when neither key is set Also extracts DEFAULT_TRIAGE_MODEL constant and caches the triage chain via a module-level singleton (getTriageChain) to avoid re-construction on every PR in a batch run. TRIAGE_MODEL env var overrides the default (anthropic/claude-haiku-4).
'anthropic/claude-haiku-4' is not a valid OpenRouter model ID — updated DEFAULT_TRIAGE_MODEL to 'anthropic/claude-haiku-4-5'. Also adds smoke-test.ts for manual end-to-end pipeline validation.
Implements distillPR() which takes a ScrapedPR that passed the filter stage and produces a schema-valid markdown draft in agent-memory/_pending/<domain>/. - Structured LLM output via Zod (domain enum, required fields) with OpenRouter-primary / Anthropic fallback, matching filter.ts pattern - Deterministic markdown assembly in code; AJV validates frontmatter before the file is written - distillFn injection option for tests (no real API calls in test suite) - Graceful degradation: LLM failure → flag-for-human + _skipped.ndjson - 25 tests, 86.31% branch coverage, all thresholds met
…ts, async I/O - Fix double-wrapped error prefix in distillPR catch block (was emitting "Distill LLM unavailable: Distill LLM unavailable: ..." on the no-key path) - Replace sync fs.*Sync calls with await fs.promises.* so batch processing does not block the event loop - Fix .slice-before-filter on review comments so non-empty reviews beyond index 2 are not silently dropped - Derive CHT_DOMAINS list in buildPrompt from the constant rather than a separate hardcoded string that could drift - Extract CHT_DOMAINS, DEFAULT_PIPELINE_LOG_PATH, DEFAULT_PIPELINE_OUTPUT_DIR to src/constants/index.ts; both filter.ts and distiller.ts now import them - Switch process.env bracket notation to dot notation in both scripts - Remove redundant `const parsed = matter` alias in distiller.spec.ts
Adds distillPR call after a distill decision so smoke-test validates the full scrape → filter → distill pipeline in one run.
… missing frontmatter
… string check gray-matter v4 caches parsed results in matter.cache[input]. When the same content string is parsed a second time the cached object is returned, which may not retain the 'matter' non-enumerable property. Checking the raw string with hasFrontmatter() before calling matter() is deterministic and cache- independent, making all apply-mode tests reliable.
- Extract buildValidator, normalizeFrontmatter, hasFrontmatter, REPO_ROOT, SCHEMA_PATH into src/scripts/schema-utils.ts; import from both open-review-pr.ts and validate-schema.ts to eliminate duplication - Fix buggy parsed.matter check in validate-schema.ts to use hasFrontmatter() - Hoist buildValidator() call to module scope in open-review-pr.ts so schema is compiled once per process, not on every openReviewPR() invocation - Replace existsSync+readdirSync TOCTOU pattern with single readdirSync+catch - Deduplicate path.basename call in writeSkipEntry - Bound uniqueBranchName loop to 99 iterations with explicit throw on exhaust - Remove phase 1/2/3 what-not-why comments
…d log update - src/scripts/run-pipeline.ts: CLI entry point wiring Scraper → Filter → Distiller for single PR (--pr) or recent window (--since / default 24h) - .github/workflows/run-pipeline.yml: daily cron (06:00 UTC) + manual dispatch that runs the pipeline and commits knowledge drafts back to the branch - package.json: add run-pipeline npm script - agent-memory/_skipped.ndjson: update with latest smoke-test run entries
…, fix auto-fixable errors - Move ajv, ajv-formats, gray-matter from devDependencies to dependencies so n/no-unpublished-require passes for src/scripts/ files - Add missing ajv to dependencies (only ajv-formats was listed) - Run eslint --fix to correct dot-notation and indent violations - Remove unused callCount variable in open-review-pr.spec.ts - Remove invalid @typescript-eslint/no-throw-literal eslint-disable comments in scraper.spec.ts (rule was renamed in typescript-eslint v6+)
- Add normalizeFrontmatter lastUpdated→last_updated aliasing test (covers schema-utils.ts lines 49-50) - Add non-Error LLM throw test in distillPR (covers distiller.ts line 275 false branch) - Add empty linkedIssues prompt test (issueContext false branch) - Add undefined prBody prompt test (null-coalescing branch) Branch coverage: 83.53% → 85.06% (279/328)
Cognitive complexity reductions (max 5 per function): - smoke-test: extract processPR helper (complexity 11→1) - run-pipeline: extract processSinglePR + errorMessage (12→3, 8→3) - open-review-pr: extract parseDraft, findValidDrafts, collectValidPlans, buildDryRunResults, promoteDomain, executeApply (26→1) - filter: extract runLlmTriage + use Set for labels (7→2, 6→3) - scraper: extract fetchMetadata, fetchDiff, fetchReviews (13→2) - validate-schema: extract processEntry + logFileResult (8→4, 9→2) Code quality warnings: - Replace child_process with node:child_process in 3 files - Use import matter from 'gray-matter' (remove unnecessary require assertions) - Use String#endsWith for bot check, RegExp#exec for match - Number.parseInt over parseInt throughout - String#replaceAll over regex replace (distiller, open-review-pr) - Flip negated condition in run-pipeline IIFE - String(fm.source_pr) to prevent [object Object] in template - files.toSorted() over files.sort() in validate-schema - Update scraper.spec.ts proxyquire key for node:child_process Coverage: 83.53% → 85.41% (281/329 branches)
- smoke-test: extract filterAndDistill helper (processPR complexity 6→3) + prefer node:os import - filter: extract lockfile/translation booleans in checkSkipRules (7→5) + extract isBugWithLinkedIssueAndMultiService/isFeatureWithLinkedIssue/ isSharedLibsWithMultiService helpers for checkDistillRules (7→3) - open-review-pr: pre-compute sourcePrStr as typed string to prevent [object Object] template literal stringification - validate-schema: add localeCompare comparator to toSorted()
Replace unbounded .* with [^/]* in the lockfile alternation so the wildcard cannot overlap the (?:^|/) anchor. Eliminates super-linear backtracking (50k-char non-match: ~0.07ms) while preserving match behavior across root-level and nested *.lock paths.
SonarCloud security hotspots (PR #109) — review summaryS5852 — ReDoS in S4036 ×6 — "PATH variable" on
These 6 are LOW-probability hotspots; assessed as acceptable risk for CI tooling rather than altered. |
sugat009
left a comment
There was a problem hiding this comment.
Review (request changes): memory distillation pipeline
Reviewed across security, the pipeline stages, schema/review-loop, and tests, with a QA manual-run pass to confirm findings empirically. Solid PR with good security fundamentals. The blocking items are narrow: one cross-PR schema reconciliation plus CI/conflict housekeeping; everything else is non-blocking follow-ups.
Verified strong: no shell injection (execFileSync with array args), no path traversal (z.enum domain + slugified filename), the S5852 ReDoS fix is real, provenance fields are not spoofable, the CI workflow uses the safe input pattern (env: indirection, not ${{ }} inside run:), and the human-review gate is solid: nothing reaches agent-memory/domains/ without passing AJV re-validation in open-review-pr, a human-initiated --apply, and a human-reviewed promotion PR.
Requested changes
-
Settle on one frontmatter contract.
agent-memory/schema.jsonconflicts with #87 (confirmed viagit merge-tree). All 30 committed agent-memory files already use #87's camelCase shape (id,category,issueNumber,issueUrl,lastUpdated,summary,services,techStack); they only pass this PR's snake_case schema via alastUpdatedtolast_updatedalias innormalizeFrontmatter, and the rich fields go unchecked. Suggest aligning on #87's shape (it matches the files and allows extra properties, so the provenance fields still fit) and updating the distiller to emit it. Worth coordinating before either PR merges, to avoid a schema conflict/regression. -
Housekeeping (these block merge today). The SonarCloud
os.tmpdir()hotspots insmoke-test.tsare the only failing check (fix withfs.mkdtempSyncor mark them safe), and there is a.gitignoremerge conflict to resolve.
question (non-blocking): OpenRouter / non-Claude stack
This pipeline reaches its LLM through OpenRouter (ChatOpenAI + baseURL: https://openrouter.ai/api/v1 in filter.ts:152 / distiller.ts:68), with a ChatAnthropic fallback. That is the first place cht-agent routes through a third-party gateway and pulls in @langchain/openai + an OPENROUTER_API_KEY; everywhere else we standardize on Claude via the src/llm LLMProvider abstraction. Could you add a short rationale in the PR/README?
- The code defaults are Claude-via-OpenRouter (
anthropic/claude-haiku-4-5,anthropic/claude-sonnet-4-5), where OpenRouter is just a proxy in front of Claude, but.env.examplerecommends open models (google/gemma-3-4b-it,deepseek/deepseek-chat-v3-0324). Which is the intended default? - If open models are the intent, a note that agent-memory drafts would be authored by non-Anthropic models. The cht-core PR content is public, so this is a quality and consistency call, not a data-privacy one.
Not asking to change the code here, just capturing the decision so the divergence from the Claude-only stack is intentional.
Secondary (non-blocking follow-ups)
- Validate drafts before writing.
distillPRhand-assembles the YAML frontmatter by string interpolation (title: ${draft.title}unquoted) and writes with no parse/AJV check, which contradicts the documented "schema-validate or log-skip". Not a memory-safety risk, sinceopen-review-prre-validates before promotion and a human gates the PR, but the code does not match its docs, and broken drafts pile up in committed_pending/and re-spam_skipped.ndjson(never cleaned). Fix: serialize withjs-yamldump()and validate (parse + AJV) before writing, routing failures to_skipped.ndjson. (validate-schemaalso skips_pendingwhile the cron commits it.) - scraper
--paginatereview merge corrupts bodies containing] [and crashes on empty pages (scraper.ts:259); use--slurpor parse per page. open-review-prpromotion is not isolated per-domain: one domain's failure aborts the rest and leaves an orphan pushed branch.- filter LLM tests stub
@langchain/anthropic, but the primary path is OpenRouter (ChatOpenAI); the full suite passes only because a sibling spec clearsOPENROUTER_API_KEYfirst (cross-spec env leakage), QA confirmed. - smaller:
--since <non-numeric>exits 0 as success; unguardedr.user.loginthrows on deleted-account reviews;source_prlink builds.../cht-core#42instead of/pull/42; andnpm run smoke-testis in the test plan but not inpackage.json.
Nits
LOCKFILE_PATTERN(filter.ts:25) matches any*.lockbasename, so a PR touching an unrelated.lockcould be mis-skipped.- A failed issue fetch still returns a linked-issue object (
scraper.ts), so a 404'd reference can flip a skip into a distill. slugify(distiller.ts:287) yields an empty slug for symbol-only or non-Latin titles (42-.md), and a re-run overwrites the prior draft (no existence check).- The draft title is interpolated into the PR-body markdown unescaped (
open-review-pr.ts:79). runLlmTriage's try/catch (filter.ts:228) is redundant; the realtriageFnalready catches internally.JSON.parseof metadata/reviews (scraper.ts:240) sits outside theScraperErrorwrap, leaking a rawSyntaxError.- A
touchesMultipleServicestest (filter.spec.ts:171) is mislabeled and its inline comment contradicts the assertion.
Thanks, this is a genuinely useful pipeline; the asks are cross-PR schema coordination plus CI housekeeping, not a redesign.
Resolves SonarCloud hotspots flagging predictable temp paths in a publicly-writable directory. mkdtempSync creates a per-run dir with a random name and 0700 permissions.
Adopts #87's converged camelCase schema.json and schema-utils.ts verbatim so the two PRs share one frontmatter contract and the 30 committed agent-memory files validate natively (no lastUpdated->last_updated alias). Distiller changes: - emit camelCase frontmatter (lastUpdated, issueNumber, issueUrl) plus the now-required services and techStack fields - derive issueNumber/issueUrl/id from the first linked issue, falling back to the PR number when the PR closes no issue - serialize frontmatter with js-yaml (correct quoting; fixes unquoted title) and AJV-validate every draft before writing — malformed drafts route to _skipped.ndjson as flag-for-human instead of landing in _pending/ Addresses the review's blocking schema-reconciliation item.
|
Thanks for the thorough review. Both blocking items are addressed (commits 1. Frontmatter contract reconciled with #87 (camelCase). Adopted #87's converged 2. Housekeeping. The Folded in "validate drafts before writing": SonarCloud (the remaining red check). The OpenRouter / LLM provider question. Addressed in the README: the OpenRouter integration is there to keep the pipeline provider-agnostic rather than locked to a single vendor. There's no mandated default — whoever runs the pipeline picks the model. (The Claude defaults in code are just one example.) One item surfaced, needs a call on #87's / #79's side. Under #87's strict Nits — all addressed ( Secondary follow-ups 1–3 — addressed ( Secondary follow-ups 4–7 — addressed ( All review items — both blocking fixes, the seven nits, and follow-ups 1–7 — are now resolved. |
- filter: narrow LOCKFILE_PATTERN to known lockfile names (no longer skips unrelated *.lock files); pattern is now fully literal (no ReDoS surface) - filter: drop redundant inner try/catch in llmTriage; runLlmTriage is the single error boundary (covers default + injected triageFn paths) - scraper: drop unresolvable linked issues instead of returning empty stubs, so a 404'd reference can't flip a filter skip into a distill - scraper: wrap metadata/reviews JSON.parse in ScraperError (carries prNumber) instead of leaking a raw SyntaxError - distiller: slugify falls back to 'untitled' for symbol-only/non-Latin titles - open-review-pr: escape Markdown special chars in draft titles in the PR body - tests: relabel mislabeled single-service filter test; add coverage for all of the above (359 passing)
- scraper: fetch reviews with `--paginate --slurp` and flatten the array-of-pages instead of string-stitching `[...][...]` — no longer corrupts review bodies containing `] [` or breaks on empty pages - open-review-pr: isolate promotion per domain — a domain failure is recorded as a 'failed' ReviewPRResult and the rest still run; clean up the orphan remote branch when PR creation fails after push - filter tests: stub both LLM providers (ChatOpenAI + ChatAnthropic) and clear both API keys per-test, removing cross-spec env leakage; add an explicit OpenRouter primary-path test 363 passing, 86% branch coverage
- run-pipeline: validate --since/--pr as positive integers (error instead of silently processing nothing); guard the CLI IIFE behind require.main so the module is importable, and cover its orchestration functions (now 100%). - scraper: handle reviews from deleted GitHub accounts (user: null) by falling back to the 'ghost' author instead of dereferencing null. - open-review-pr: resolve source_pr 'owner/repo#N' refs to working /pull/N URLs via a new sourcePrUrl helper, instead of a dead repo-home anchor. - package.json: add the smoke-test npm script. Tests: 380 passing; coverage 97.21% stmts / 86.65% branch (>= 95% gate).
Number.parseInt silently truncated '123abc' -> 123 and '1.5' -> 1, so partially-numeric flag values passed the positive-integer check. Validate the raw string with /^[1-9]\d*$/ before parsing, via a parsePositiveIntArg helper. Adds regression tests for trailing characters and decimals.
A digit string large enough to overflow Number.parseInt passed the regex but produced a non-safe-integer (and an invalid lookback date downstream). Parse with Number() and require Number.isSafeInteger. Adds a regression test.
- run-pipeline: use Number.NaN over NaN (S7773); throw TypeError for the argument-validation guard (S7786). - open-review-pr: use String.raw for the Markdown-escape replacement (S7780); RegExp.exec() over String#match in sourcePrUrl (S6594); reduce cognitive complexity of promoteDomain and executeApply (S3776) by extracting stageDrafts, deleteRemoteBranch, and promoteDomainSafely helpers. Behavior unchanged; 384 passing, coverage 97.2% (>= 95% gate).
sugat009
left a comment
There was a problem hiding this comment.
Approving. Re-reviewed at head ceea34d; every requested change and finding from the prior review is resolved, verified in code (not just from the reply):
- Frontmatter contract converged with #87 (
schema.json+schema-utils.tsare now byte-identical). The distiller emits camelCaselastUpdatedplus the requiredid/issueNumber/issueUrl/services/techStack, serializes viajs-yamldump(), and AJV-validates before writing (invalid drafts route toflag-for-human, never_pending). - Scraper:
--paginate --slurp(no more[...][...]string-stitch), literal lockfile pattern,ScraperError-wrapped parse, unresolvable issues dropped, deleted-account guard. run-pipeline:--since/--prreject non-numeric, partially-numeric, decimal, non-positive, and oversized values.open-review-pr: per-domain isolation + orphan-branch cleanup, markdown-escaped titles, correct/pull/link.- Filter/tests: both providers stubbed and both keys cleared with an explicit OpenRouter primary-path test (the cross-spec env leak is gone; the suite passes even with poisoned keys), mislabeled test fixed, new run-pipeline spec.
- Housekeeping/docs:
mkdtempSync+ asmoke-testscript, slugifyuntitledfallback, README OpenRouter rationale.
Thanks for the thorough turnaround, including the deferred follow-ups and the extra input-validation hardening.
Two non-blocking follow-ups (not gating this approval):
agent-memory/_skipped.ndjsonis committed with ~7 dev-run noise lines (the reset did not stick); worth emptying or gitignoring so it does not accumulate.- The distiller emits 6 H2 body sections, but #87's
validate-schemabody check requires 8 (## Testing,## Related Issues), so a promoted draft would fail the full-corpus validator. Forward-looking convergence item, bounded by the human promotion gate.
(SonarCloud Code Analysis is advisory now; the remaining S4036 gh/git PATH hotspots are legitimate execFileSync calls and can be marked Safe when convenient.)
|
You guys have caught a lot of the same issues I had when running it (i.e. making sure schema's matched and included all fields such as related categories, sub-issues etc.) Some feedback, but I'm not sure if you want to address it in this PR or next ones:
For PR 10804: Domain RationaleFit: strong The change operates entirely on the contact profile page, the contact view-model generator, and the child-contact hierarchy display — squarely contact lookup and presentation. No sync, permission, or config concerns are involved. |
A `/**` line was dropped (likely during rebase), leaving the comment body as bare tokens that TypeScript parsed as code — causing 30+ TS errors and the "Build, lint, and test" CI failure.
…, CLI, and CI workflow (#128) * feat(#108): add pipeline directory scaffolding and types - Create agent-memory/_pending/<domain>/ for all 8 CHT domains - Add empty agent-memory/_skipped.ndjson for filter audit log - Define pipeline types in src/types/pipeline.ts: ScrapedPR, LinkedIssue, ReviewComment, FilterDecision, SkipLogEntry, ScraperError * feat(#108): implement PR scraper using gh CLI Synchronous scraper that collects structured data from medic/cht-core PRs for the memory distillation pipeline. - Fetches PR metadata, diff, review summaries, and linked issues - Uses execFileSync with array args (no shell injection risk) - 50 MB maxBuffer with ENOBUFS → ScraperError - Graceful fallback for org-membership (read:org not required) - Linked issues extracted via regex; GitHub sidebar links documented as a known limitation (requires GraphQL) - PENDING reviews filtered out - 22 unit tests, all coverage thresholds met * feat(#108): extend schema with provenance fields and add validator - Add 10 optional frontmatter fields to schema.json: source_pr, source_sha, distilled_at, reviewed_by, reviewed_at, confidence, entities, concepts, related_issues, stale - All new fields are optional (backward-compatible with existing entries) - Update TEMPLATE.md with commented stubs and field reference table - Add src/scripts/validate-schema.ts (gray-matter + ajv) to validate all agent-memory /**/*.md frontmatter; 30/30 existing entries pass - Add npm run validate-schema script - Fix YAML syntax error in 10729-smsparser-bugs-typos.md (unquoted colon) * fix(#108): extend linked-issue regex to match full GitHub issue URLs Bare keyword regex (e.g. "Fixes #123") missed the URL form used by some cht-core PRs ("Fixes https://github.com/…/issues/123"). Updated pattern captures both forms and deduplicates across them. Added 2 tests: URL extraction and bare+URL dedup. * feat(#108): add filter stage with deterministic rules and LLM triage Three-stage pipeline filter for ScrapedPR objects: - Stage 1: 5 deterministic skip rules (bot, revert, chore/docs/ci/build, lockfile-only, translation-only) - Stage 2: 3 deterministic distill rules (bug+issue+multiservice, feature+issue, shared-libs+multiconsumer) - Stage 3: LLM triage via ChatAnthropic with Zod structured output; graceful fallback to flag-for-human on failure or missing API key Also adds ScrapedPR.author field, FilterResult/FilterOptions types, and _skipped.ndjson audit log (append-only, skip/flag decisions only). DoD items verified: - All 5 skip rules + all 3 distill rules implemented and tested - touchesMultipleServices edge cases covered - LLM failure -> flag-for-human, no throw - skipLlm option bypasses LLM call - 211 tests passing, branches 89.24%, lines 99.29% * feat(#108): add OpenRouter support with Anthropic fallback to filter triage Switch LLM triage from hardcoded ChatAnthropic to a dual-provider setup: - OpenRouter (OPENROUTER_API_KEY) as primary via ChatOpenAI - Anthropic direct (ANTHROPIC_API_KEY) as fallback - Graceful flag-for-human when neither key is set Also extracts DEFAULT_TRIAGE_MODEL constant and caches the triage chain via a module-level singleton (getTriageChain) to avoid re-construction on every PR in a batch run. TRIAGE_MODEL env var overrides the default (anthropic/claude-haiku-4). * fix(#108): correct OpenRouter model ID and add smoke-test script 'anthropic/claude-haiku-4' is not a valid OpenRouter model ID — updated DEFAULT_TRIAGE_MODEL to 'anthropic/claude-haiku-4-5'. Also adds smoke-test.ts for manual end-to-end pipeline validation. * feat(#108): add distiller stage — LLM-powered knowledge draft generator Implements distillPR() which takes a ScrapedPR that passed the filter stage and produces a schema-valid markdown draft in agent-memory/_pending/<domain>/. - Structured LLM output via Zod (domain enum, required fields) with OpenRouter-primary / Anthropic fallback, matching filter.ts pattern - Deterministic markdown assembly in code; AJV validates frontmatter before the file is written - distillFn injection option for tests (no real API calls in test suite) - Graceful degradation: LLM failure → flag-for-human + _skipped.ndjson - 25 tests, 86.31% branch coverage, all thresholds met * refactor(#108): simplify distiller — fix bugs, extract shared constants, async I/O - Fix double-wrapped error prefix in distillPR catch block (was emitting "Distill LLM unavailable: Distill LLM unavailable: ..." on the no-key path) - Replace sync fs.*Sync calls with await fs.promises.* so batch processing does not block the event loop - Fix .slice-before-filter on review comments so non-empty reviews beyond index 2 are not silently dropped - Derive CHT_DOMAINS list in buildPrompt from the constant rather than a separate hardcoded string that could drift - Extract CHT_DOMAINS, DEFAULT_PIPELINE_LOG_PATH, DEFAULT_PIPELINE_OUTPUT_DIR to src/constants/index.ts; both filter.ts and distiller.ts now import them - Switch process.env bracket notation to dot notation in both scripts - Remove redundant `const parsed = matter` alias in distiller.spec.ts * feat(#108): extend smoke-test to run distiller stage end-to-end Adds distillPR call after a distill decision so smoke-test validates the full scrape → filter → distill pipeline in one run. * test: reproduce bug — second call with same draft content rejected as missing frontmatter * fix: replace gray-matter .matter property check with hasFrontmatter() string check gray-matter v4 caches parsed results in matter.cache[input]. When the same content string is parsed a second time the cached object is returned, which may not retain the 'matter' non-enumerable property. Checking the raw string with hasFrontmatter() before calling matter() is deterministic and cache- independent, making all apply-mode tests reliable. * fix: use dot notation for Record<string,unknown> property access * feat(#108): add OpenReviewOptions/ReviewPRResult types and open-review-pr npm script * docs(#108): document OPENROUTER_API_KEY, TRIAGE_MODEL, DISTILL_MODEL in .env.example * chore: update skipped log with smoke-test run entries * refactor(#108): extract shared schema-utils and clean up open-review-pr - Extract buildValidator, normalizeFrontmatter, hasFrontmatter, REPO_ROOT, SCHEMA_PATH into src/scripts/schema-utils.ts; import from both open-review-pr.ts and validate-schema.ts to eliminate duplication - Fix buggy parsed.matter check in validate-schema.ts to use hasFrontmatter() - Hoist buildValidator() call to module scope in open-review-pr.ts so schema is compiled once per process, not on every openReviewPR() invocation - Replace existsSync+readdirSync TOCTOU pattern with single readdirSync+catch - Deduplicate path.basename call in writeSkipEntry - Bound uniqueBranchName loop to 99 iterations with explicit throw on exhaust - Remove phase 1/2/3 what-not-why comments * feat(#108): add run-pipeline CLI, GitHub Actions workflow, and skipped log update - src/scripts/run-pipeline.ts: CLI entry point wiring Scraper → Filter → Distiller for single PR (--pr) or recent window (--since / default 24h) - .github/workflows/run-pipeline.yml: daily cron (06:00 UTC) + manual dispatch that runs the pipeline and commits knowledge drafts back to the branch - package.json: add run-pipeline npm script - agent-memory/_skipped.ndjson: update with latest smoke-test run entries * fix(#108): resolve CI lint failures — promote ajv/gray-matter to deps, fix auto-fixable errors - Move ajv, ajv-formats, gray-matter from devDependencies to dependencies so n/no-unpublished-require passes for src/scripts/ files - Add missing ajv to dependencies (only ajv-formats was listed) - Run eslint --fix to correct dot-notation and indent violations - Remove unused callCount variable in open-review-pr.spec.ts - Remove invalid @typescript-eslint/no-throw-literal eslint-disable comments in scraper.spec.ts (rule was renamed in typescript-eslint v6+) * test(#108): add branch-coverage tests to meet 85% threshold - Add normalizeFrontmatter lastUpdated→last_updated aliasing test (covers schema-utils.ts lines 49-50) - Add non-Error LLM throw test in distillPR (covers distiller.ts line 275 false branch) - Add empty linkedIssues prompt test (issueContext false branch) - Add undefined prBody prompt test (null-coalescing branch) Branch coverage: 83.53% → 85.06% (279/328) * fix(#108): resolve all SonarCloud quality gate failures Cognitive complexity reductions (max 5 per function): - smoke-test: extract processPR helper (complexity 11→1) - run-pipeline: extract processSinglePR + errorMessage (12→3, 8→3) - open-review-pr: extract parseDraft, findValidDrafts, collectValidPlans, buildDryRunResults, promoteDomain, executeApply (26→1) - filter: extract runLlmTriage + use Set for labels (7→2, 6→3) - scraper: extract fetchMetadata, fetchDiff, fetchReviews (13→2) - validate-schema: extract processEntry + logFileResult (8→4, 9→2) Code quality warnings: - Replace child_process with node:child_process in 3 files - Use import matter from 'gray-matter' (remove unnecessary require assertions) - Use String#endsWith for bot check, RegExp#exec for match - Number.parseInt over parseInt throughout - String#replaceAll over regex replace (distiller, open-review-pr) - Flip negated condition in run-pipeline IIFE - String(fm.source_pr) to prevent [object Object] in template - files.toSorted() over files.sort() in validate-schema - Update scraper.spec.ts proxyquire key for node:child_process Coverage: 83.53% → 85.41% (281/329 branches) * fix(#108): resolve remaining 7 SonarCloud issues - smoke-test: extract filterAndDistill helper (processPR complexity 6→3) + prefer node:os import - filter: extract lockfile/translation booleans in checkSkipRules (7→5) + extract isBugWithLinkedIssueAndMultiService/isFeatureWithLinkedIssue/ isSharedLibsWithMultiService helpers for checkDistillRules (7→3) - open-review-pr: pre-compute sourcePrStr as typed string to prevent [object Object] template literal stringification - validate-schema: add localeCompare comparator to toSorted() * fix(#108): extract checkSkipRules helpers to eliminate && complexity (7→5) * fix(#108): resolve S5852 ReDoS in LOCKFILE_PATTERN regex Replace unbounded .* with [^/]* in the lockfile alternation so the wildcard cannot overlap the (?:^|/) anchor. Eliminates super-linear backtracking (50k-char non-match: ~0.07ms) while preserving match behavior across root-level and nested *.lock paths. * fix(#108): use mkdtempSync for smoke-test temp paths Resolves SonarCloud hotspots flagging predictable temp paths in a publicly-writable directory. mkdtempSync creates a per-run dir with a random name and 0700 permissions. * feat(#108): reconcile frontmatter schema with #87 (camelCase) Adopts #87's converged camelCase schema.json and schema-utils.ts verbatim so the two PRs share one frontmatter contract and the 30 committed agent-memory files validate natively (no lastUpdated->last_updated alias). Distiller changes: - emit camelCase frontmatter (lastUpdated, issueNumber, issueUrl) plus the now-required services and techStack fields - derive issueNumber/issueUrl/id from the first linked issue, falling back to the PR number when the PR closes no issue - serialize frontmatter with js-yaml (correct quoting; fixes unquoted title) and AJV-validate every draft before writing — malformed drafts route to _skipped.ndjson as flag-for-human instead of landing in _pending/ Addresses the review's blocking schema-reconciliation item. * chore(#108): reset _skipped.ndjson to empty (drop dev-run log noise) * docs(#108): document OpenRouter provider-agnostic rationale * fix(#108): address review nits in pipeline stages - filter: narrow LOCKFILE_PATTERN to known lockfile names (no longer skips unrelated *.lock files); pattern is now fully literal (no ReDoS surface) - filter: drop redundant inner try/catch in llmTriage; runLlmTriage is the single error boundary (covers default + injected triageFn paths) - scraper: drop unresolvable linked issues instead of returning empty stubs, so a 404'd reference can't flip a filter skip into a distill - scraper: wrap metadata/reviews JSON.parse in ScraperError (carries prNumber) instead of leaking a raw SyntaxError - distiller: slugify falls back to 'untitled' for symbol-only/non-Latin titles - open-review-pr: escape Markdown special chars in draft titles in the PR body - tests: relabel mislabeled single-service filter test; add coverage for all of the above (359 passing) * fix(#108): address secondary review follow-ups 1-3 - scraper: fetch reviews with `--paginate --slurp` and flatten the array-of-pages instead of string-stitching `[...][...]` — no longer corrupts review bodies containing `] [` or breaks on empty pages - open-review-pr: isolate promotion per domain — a domain failure is recorded as a 'failed' ReviewPRResult and the rest still run; clean up the orphan remote branch when PR creation fails after push - filter tests: stub both LLM providers (ChatOpenAI + ChatAnthropic) and clear both API keys per-test, removing cross-spec env leakage; add an explicit OpenRouter primary-path test 363 passing, 86% branch coverage * fix(#108): address secondary review follow-ups 4-7 - run-pipeline: validate --since/--pr as positive integers (error instead of silently processing nothing); guard the CLI IIFE behind require.main so the module is importable, and cover its orchestration functions (now 100%). - scraper: handle reviews from deleted GitHub accounts (user: null) by falling back to the 'ghost' author instead of dereferencing null. - open-review-pr: resolve source_pr 'owner/repo#N' refs to working /pull/N URLs via a new sourcePrUrl helper, instead of a dead repo-home anchor. - package.json: add the smoke-test npm script. Tests: 380 passing; coverage 97.21% stmts / 86.65% branch (>= 95% gate). * fix(#108): reject partially-numeric --since/--pr (roborev MEDIUM) Number.parseInt silently truncated '123abc' -> 123 and '1.5' -> 1, so partially-numeric flag values passed the positive-integer check. Validate the raw string with /^[1-9]\d*$/ before parsing, via a parsePositiveIntArg helper. Adds regression tests for trailing characters and decimals. * fix(#108): reject oversized --since/--pr values (roborev LOW) A digit string large enough to overflow Number.parseInt passed the regex but produced a non-safe-integer (and an invalid lookback date downstream). Parse with Number() and require Number.isSafeInteger. Adds a regression test. * refactor(#108): clear SonarCloud findings on PR #109 new code - run-pipeline: use Number.NaN over NaN (S7773); throw TypeError for the argument-validation guard (S7786). - open-review-pr: use String.raw for the Markdown-escape replacement (S7780); RegExp.exec() over String#match in sourcePrUrl (S6594); reduce cognitive complexity of promoteDomain and executeApply (S3776) by extracting stageDrafts, deleteRemoteBranch, and promoteDomainSafely helpers. Behavior unchanged; 384 passing, coverage 97.2% (>= 95% gate). * fix(#108): restore missing JSDoc opening tag in constants/index.ts A `/**` line was dropped (likely during rebase), leaving the comment body as bare tokens that TypeScript parsed as code — causing 30+ TS errors and the "Build, lint, and test" CI failure.
Summary
Adds an automated pipeline that grows
agent-memory/from mergedcht-corePRs — no more hand-writing knowledge entries. It runs daily (or on demand), looks at each merged PR, decides whether it's worth capturing, and uses an LLM to draft a structured note for a human to review.Flow:
scrape PR → filter (keep or skip) → distill into a draft → human reviewsEach stage is its own module, independently tested, and runnable from one CLI or the CI workflow.
How each stage works
scraper.ts) — uses theghCLI to gather everything about a PR: title, labels, author, file list, full diff, linked issues, and reviewer comments. No LLM, just data assembly.filter.ts) — decides keep vs. skip in two passes: fast deterministic rules first (skip chore/test/dependency-only or trivial PRs; keep code changes that have a linked issue), then an LLM judges anything ambiguous. Every skip is logged to_skipped.ndjsonwith a reason so the rules can be audited and tuned.distiller.ts) — sends the kept PR to an LLM and writes a structured draft toagent-memory/_pending/<domain>/<pr>.md, complete with provenance frontmatter (source_pr,source_sha,confidence, etc.). Drafts that fail schema validation are logged to_skipped.ndjsoninstead of being written.How to run it
run-pipelineCLI (run-pipeline.ts) — wires all three stages. Flags:--pr <n>(one PR),--since <hours>(recent merges, default 24),--repo <owner/repo>(defaultmedic/cht-core). Exits non-zero if any PR fails..github/workflows/run-pipeline.yml) — runs daily at 06:00 UTC over the last 24h, plus a manualworkflow_dispatchtrigger. It commits new drafts and_skipped.ndjsonupdates back with[skip ci].open-review-pr(open-review-pr.ts) — the human side: opens each_pending/draft in the editor, validates on save, and moves approved drafts to their finalagent-memory/domains/home.Config
Set in
.env(see.env.example):OPENROUTER_API_KEY,TRIAGE_MODEL,DISTILL_MODEL,GH_TOKEN.What else is in here
schema-utils.ts(AJV validator + frontmatter helpers),src/types/pipeline.ts(all pipeline types),src/constants/, andagent-memory/schema.json+TEMPLATE.mddefining the note format.scraper,filter,distiller,open-review-pr) coveringghmocking, every filter branch, LLM happy/invalid paths, and the review loop. 265 passing.[^/]*instead of.*). The remaining S4036 "PATH" hotspots areexecFileSync('gh', …)calls — reviewed and accepted: no shell is used, args are arrays, and they only run in controlled CI; a hardcoded path would break cross-platform portability.Test plan
npm test— all unit tests passnpm run smoke-test— full pipeline against three live PRs (11057, 11022, 11077); expect at least onedistilland oneskipnpm run run-pipeline -- --pr 11057— produces a valid draft in_pending/npm run run-pipeline -- --since 48— runs clean;_skipped.ndjsongrows for skipsworkflow_dispatchwithpr_number=11057— workflow commits a draft back to the branchnpm run open-review-pr— opens, validates, and moves a draft to the right domainCloses #108 and lays the foundation for #101