Skip to content

feat(#108): memory distillation pipeline — scraper, filter, distiller, CLI, and CI workflow#109

Merged
alexosugo merged 37 commits into
mainfrom
feat/108-memory-pipeline-scaffolding
Jun 24, 2026
Merged

feat(#108): memory distillation pipeline — scraper, filter, distiller, CLI, and CI workflow#109
alexosugo merged 37 commits into
mainfrom
feat/108-memory-pipeline-scaffolding

Conversation

@alexosugo

@alexosugo alexosugo commented May 28, 2026

Copy link
Copy Markdown
Contributor

Summary

Adds an automated pipeline that grows agent-memory/ from merged cht-core PRs — 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 reviews

cht-core PR ──► Scraper ──► Filter ──► Distiller ──► _pending/ draft ──► human review
              (gh CLI)   (rules+LLM)  (LLM)

Each stage is its own module, independently tested, and runnable from one CLI or the CI workflow.

How each stage works

  • Scraper (scraper.ts) — uses the gh CLI to gather everything about a PR: title, labels, author, file list, full diff, linked issues, and reviewer comments. No LLM, just data assembly.
  • Filter (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.ndjson with a reason so the rules can be audited and tuned.
  • Distiller (distiller.ts) — sends the kept PR to an LLM and writes a structured draft to agent-memory/_pending/<domain>/<pr>.md, complete with provenance frontmatter (source_pr, source_sha, confidence, etc.). Drafts that fail schema validation are logged to _skipped.ndjson instead of being written.

How to run it

  • run-pipeline CLI (run-pipeline.ts) — wires all three stages. Flags: --pr <n> (one PR), --since <hours> (recent merges, default 24), --repo <owner/repo> (default medic/cht-core). Exits non-zero if any PR fails.
  • CI workflow (.github/workflows/run-pipeline.yml) — runs daily at 06:00 UTC over the last 24h, plus a manual workflow_dispatch trigger. It commits new drafts and _skipped.ndjson updates 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 final agent-memory/domains/ home.

Config

Set in .env (see .env.example): OPENROUTER_API_KEY, TRIAGE_MODEL, DISTILL_MODEL, GH_TOKEN.

What else is in here

  • Shared pieces: schema-utils.ts (AJV validator + frontmatter helpers), src/types/pipeline.ts (all pipeline types), src/constants/, and agent-memory/schema.json + TEMPLATE.md defining the note format.
  • Tests: ~2,100 lines of unit tests across four spec files (scraper, filter, distiller, open-review-pr) covering gh mocking, every filter branch, LLM happy/invalid paths, and the review loop. 265 passing.
  • SonarCloud: Fixed an S5852 ReDoS in the lockfile-matching regex ([^/]* instead of .*). The remaining S4036 "PATH" hotspots are execFileSync('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 pass
  • npm run smoke-test — full pipeline against three live PRs (11057, 11022, 11077); expect at least one distill and one skip
  • npm run run-pipeline -- --pr 11057 — produces a valid draft in _pending/
  • npm run run-pipeline -- --since 48 — runs clean; _skipped.ndjson grows for skips
  • workflow_dispatch with pr_number=11057 — workflow commits a draft back to the branch
  • npm run open-review-pr — opens, validates, and moves a draft to the right domain

Closes #108 and lays the foundation for #101

alexosugo added 18 commits May 12, 2026 19:19
- 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.
… 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
Comment thread src/scripts/smoke-test.ts Fixed
Comment thread src/scripts/smoke-test.ts Fixed
@alexosugo alexosugo changed the title feat(#108): memory pipeline scaffolding — scraper, filter, distiller, CLI, and CI workflow feat(#108): memory distillation pipeline — scraper, filter, distiller, CLI, and CI workflow May 28, 2026
alexosugo added 6 commits May 28, 2026 17:45
…, 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.
@alexosugo

Copy link
Copy Markdown
Contributor Author

SonarCloud security hotspots (PR #109) — review summary

S5852 — ReDoS in LOCKFILE_PATTERN (filter.ts:23): FIXED (commit on this branch).
Replaced the unbounded .* with [^/]* in the lockfile alternation so the wildcard can no longer overlap the (?:^|/) anchor. This bounds backtracking to a single path segment — a 50k-char pathological non-match now resolves in ~0.07ms — while preserving match behavior across root-level and nested *.lock paths. Verified by the existing filter test suite (265 passing) plus edge-case checks.

S4036 ×6 — "PATH variable" on gh calls (scraper.ts:54,94,126,164,198, run-pipeline.ts:74): reviewed, accepted as-is.
These are all execFileSync('gh', [...args]):

  • execFileSync with an args array uses no shell, so command/argument injection is not possible.
  • The residual $PATH-hijack risk is purely environmental; these scripts run only in controlled CI (and local dev), not against untrusted PATH.
  • Hardcoding an absolute path is not portable (gh lives at /opt/homebrew/bin, /usr/local/bin, or /usr/bin depending on platform) and the child-process mocks in the test suite would mask a wrong path, so the fix would be both fragile and untestable.

These 6 are LOW-probability hotspots; assessed as acceptable risk for CI tooling rather than altered.

@alexosugo alexosugo moved this from Todo to In Review in CHT Multi-Agent System (cht-agent) Jun 3, 2026

@sugat009 sugat009 left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

  1. Settle on one frontmatter contract. agent-memory/schema.json conflicts with #87 (confirmed via git 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 a lastUpdated to last_updated alias in normalizeFrontmatter, 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.

  2. Housekeeping (these block merge today). The SonarCloud os.tmpdir() hotspots in smoke-test.ts are the only failing check (fix with fs.mkdtempSync or mark them safe), and there is a .gitignore merge 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.example recommends 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. distillPR hand-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, since open-review-pr re-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 with js-yaml dump() and validate (parse + AJV) before writing, routing failures to _skipped.ndjson. (validate-schema also skips _pending while the cron commits it.)
  • scraper --paginate review merge corrupts bodies containing ] [ and crashes on empty pages (scraper.ts:259); use --slurp or parse per page.
  • open-review-pr promotion 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 clears OPENROUTER_API_KEY first (cross-spec env leakage), QA confirmed.
  • smaller: --since <non-numeric> exits 0 as success; unguarded r.user.login throws on deleted-account reviews; source_pr link builds .../cht-core#42 instead of /pull/42; and npm run smoke-test is in the test plan but not in package.json.

Nits

  • LOCKFILE_PATTERN (filter.ts:25) matches any *.lock basename, so a PR touching an unrelated .lock could 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 real triageFn already catches internally.
  • JSON.parse of metadata/reviews (scraper.ts:240) sits outside the ScraperError wrap, leaking a raw SyntaxError.
  • A touchesMultipleServices test (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.
@alexosugo

alexosugo commented Jun 16, 2026

Copy link
Copy Markdown
Contributor Author

Thanks for the thorough review. Both blocking items are addressed (commits 9bcb508, 6098573), plus the "validate before writing" follow-up and the SonarCloud housekeeping.

1. Frontmatter contract reconciled with #87 (camelCase). Adopted #87's converged schema.json and schema-utils.ts verbatim, so the two PRs now share one contract and won't conflict when either merges. The committed agent-memory files validate natively (the lastUpdated → last_updated alias is gone), and the distiller now emits camelCase frontmatter including the now-required issueNumber, issueUrl, services, and techStack — derived from the first linked issue, falling back to the PR number when no issue is closed.

2. Housekeeping. The os.tmpdir() hotspots in smoke-test.ts are fixed with fs.mkdtempSync (random name, 0700 perms). The .gitignore conflict was already resolved by the earlier main merge (GitHub reports MERGEABLE). I also reset _skipped.ndjson to empty, dropping accumulated dev-run noise — this covers your "never cleaned" note.

Folded in "validate drafts before writing": distillPR now serializes frontmatter with js-yaml dump() (correct quoting — fixes the unquoted-title bug) and AJV-validates every draft before writing. Invalid drafts route to _skipped.ndjson as flag-for-human instead of landing in _pending/.

SonarCloud (the remaining red check). The mkdtempSync fix cleared the temp-dir hotspots. What's left is a different set — S4036 ("PATH variable") on the gh/git execFileSync calls in the scraper and pipeline runner. Invoking these tools by name via PATH is the standard approach for CI and local dev; the only code-level "fix" is hardcoding absolute binary paths, which breaks portability. The correct resolution is to mark those hotspots Safe in the SonarCloud UI (needs project access). This check is informational — main has no required status checks, so it doesn't block merge.

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 CHTService enum, 4 already-committed contacts files from #79 fail validation because they use services: shared-libs / config. This isn't introduced here and isn't in #109's CI path, but it's a real #87#79 data divergence: either the enum grows to include those values, or the 4 files are corrected. I left another PR's committed data untouched.

Nits — all addressed (b3a89f0): LOCKFILE_PATTERN narrowed to known lockfile names (no longer skips unrelated *.lock files, and now fully literal — no ReDoS surface); unresolvable linked issues are dropped instead of returned as empty stubs (so a 404'd reference can't flip a skip into a distill); JSON.parse of metadata/reviews is wrapped in ScraperError; slugify falls back to untitled for symbol-only/non-Latin titles; draft titles are Markdown-escaped in the PR body; the redundant double error-catch in the triage path is collapsed to one boundary; and the mislabeled filter test is fixed.

Secondary follow-ups 1–3 — addressed (30bda57): the scraper now fetches reviews with --paginate --slurp and flattens the array-of-pages instead of string-stitching [...][...] (no longer corrupts bodies containing ] [ or breaks on empty pages); per-domain promotion is isolated (a domain failure is recorded and the rest still run, and an orphan remote branch is cleaned up if PR creation fails after push); and the filter tests now stub both LLM providers and clear both API keys per test (removing the cross-spec env leakage), with an explicit OpenRouter primary-path test added.

Secondary follow-ups 4–7 — addressed (17ab417): run-pipeline now rejects a non-numeric/non-positive --since (or --pr) with a clear error instead of silently processing nothing; reviews from deleted GitHub accounts (user: null) fall back to the ghost author rather than throwing; source_pr references resolve to working /pull/<n> URLs instead of a dead repo-home anchor; and the smoke-test npm script is now defined. The CLI entry point is also guarded so its orchestration is unit-tested (100% coverage on that module).

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 sugat009 left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.ts are now byte-identical). The distiller emits camelCase lastUpdated plus the required id/issueNumber/issueUrl/services/techStack, serializes via js-yaml dump(), and AJV-validates before writing (invalid drafts route to flag-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/--pr reject 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 + a smoke-test script, slugify untitled fallback, 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):

  1. agent-memory/_skipped.ndjson is committed with ~7 dev-run noise lines (the reset did not stick); worth emptying or gitignoring so it does not accumulate.
  2. The distiller emits 6 H2 body sections, but #87's validate-schema body 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.)

@Hareet

Hareet commented Jun 17, 2026

Copy link
Copy Markdown
Member

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:

  • Claude Code CLI factory (allows claude p to populate this)
  • The info below was already captured during the LLM call, and I found it very helpful to insert into the context doc, in case we want to deterministically re-organize domains or specific sub-issues.

For PR 10804:

Domain Rationale

Fit: 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.
@alexosugo alexosugo merged commit f4af7fb into main Jun 24, 2026
3 of 4 checks passed
@alexosugo alexosugo deleted the feat/108-memory-pipeline-scaffolding branch June 24, 2026 06:50
@alexosugo alexosugo restored the feat/108-memory-pipeline-scaffolding branch June 24, 2026 06:55
alexosugo added a commit that referenced this pull request Jun 24, 2026
…, 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.
@alexosugo alexosugo deleted the feat/108-memory-pipeline-scaffolding branch June 24, 2026 13:56
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Development

Successfully merging this pull request may close these issues.

Memory scaffolding: domain-agnostic pipeline and wiki conventions

4 participants