Skip to content

fix(#136): memory pipeline hardening + issue relinking (#135) - #138

Merged
sugat009 merged 41 commits into
mainfrom
feat/136-memory-pipeline-feedback
Sep 29, 2026
Merged

sugat009 merged 41 commits into
mainfrom
feat/136-memory-pipeline-feedback

Conversation

@alexosugo

@alexosugo alexosugo commented Jul 2, 2026 •

Copy link
Copy Markdown
Contributor

Description

Two related pieces of work on the memory distillation pipeline:

#135 — Issue resolution and relinking: replaces the buggy pr.prNumber-as-issue-number aliasing with proper GitHub issue resolution via closingIssuesReferences + title scope + body keywords, plus a one-off relink-issues tool to repair previously mislinked drafts.

#136 — Memory pipeline hardening (first-run post-mortem follow-through):

  • CI guard rejecting mislinked/slug-contradicting drafts
  • Cross-domain dedup (dedupeByIssueId) collapsing backport/multi-PR duplicates into a single canonical draft
  • Prompt hardening (CONSTRAINTS block) and HTML boilerplate stripping
  • Adaptive issue-body budget for near-empty PRs
  • Confidence gradient replacing hardcoded 'medium'
  • Per-run reconciliation report
  • Unambiguous slug separator between conventional-commit type and issue number
  • Fix for dedupeByIssueId's sort comparator: returned NaN (non-deterministic ordering) when two drafts for the same issue both lacked source_pr — flagged by roborev review #223, fixed with a stable path tiebreaker, regression tests added

#135
#136

Code review checklist

  • Readable: Concise, well named, follows the style guide.
  • Tested: Unit tests added/updated (dedup, distiller, gh-classify, issue-linkage, reconcile, relink-issues, run-pipeline, scraper)
  • Backwards compatible: Works with existing data; includes a one-off relink-issues repair tool for previously mislinked drafts.

License

The software is provided under AGPL-3.0. Contributions to this project are accepted under the same license.

alexosugo added a commit that referenced this pull request Jul 2, 2026
- dedup.ts: extract groupByIssueId/collapseGroup/draftId to bring
  dedupeByIssueId's cognitive complexity under threshold; avoid
  [object Object] stringification of a non-primitive frontmatter id
- distiller.ts: group BACKPORT_MARKER regex alternation to make
  operator precedence explicit
- open-review-pr.ts: extract validateDraft, buildPlansByDomain,
  buildSkippedDomains, rewriteCanonicalFrontmatter, and
  removeDroppedDrafts to bring three functions' cognitive complexity
  under threshold
- dedup.spec.ts: use to.be.null/to.be.undefined instead of
  to.equal(null/undefined) for more specific assertions
@alexosugo
alexosugo requested a review from Hareet July 2, 2026 13:05
alexosugo added a commit that referenced this pull request Jul 8, 2026
- Flip issue-resolution precedence to match shipped issue-linkage.ts
  (closingIssuesReferences > title > body, descending authority)
- Fix PR 10623 hallucination facts: only user-contact.service.ts is
  invented; message.pipe.ts/reducers/tasks.ts are real
- Narrow #129 open gaps to deduplication (title-parse + forward-write
  path + nested-chain case already shipped and tested)
- Reframe roadmap as tracking #138; rewrite rank 4 to the shipped
  require-an-issue-or-skip approach
- Relabel 60/107 as the four fully-reviewed domains, not corpus-wide
- Appendix: #10036 x4, subDomain-schema draft caveat, verbatim 8675/8843
  titles, CouchDB 9960/10014 close different issues
@Hareet

Hareet commented Jul 9, 2026

Copy link
Copy Markdown
Member

Ah! I didn't realize there was a merge order dependency with #129 . Can you rebase with main?

@alexosugo
alexosugo force-pushed the feat/136-memory-pipeline-feedback branch from 70e428d to 8e33966 Compare July 10, 2026 10:58
alexosugo added a commit that referenced this pull request Jul 10, 2026
- dedup.ts: extract groupByIssueId/collapseGroup/draftId to bring
  dedupeByIssueId's cognitive complexity under threshold; avoid
  [object Object] stringification of a non-primitive frontmatter id
- distiller.ts: group BACKPORT_MARKER regex alternation to make
  operator precedence explicit
- open-review-pr.ts: extract validateDraft, buildPlansByDomain,
  buildSkippedDomains, rewriteCanonicalFrontmatter, and
  removeDroppedDrafts to bring three functions' cognitive complexity
  under threshold
- dedup.spec.ts: use to.be.null/to.be.undefined instead of
  to.equal(null/undefined) for more specific assertions

@Hareet Hareet 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.

Some quick fixes and let's get this merged in. We will want to run the dedup on the memory/promote-* PRs when those are rebased and before they are merged.

Ran the suite locally on Node 22: build clean, 1057 tests passing (both new spec files execute in full), lint scoped to the PR's files clean.

Blockers:

  • issue (blocking): AGENTS.md is unrelated boilerplate that contradicts repo policy. It is the stock beads/bd onboard template: it mandates git push ("Work is NOT complete until git push succeeds", "NEVER stop before pushing", "If push fails, resolve and retry until it succeeds"), git pull --rebase, and bd sync; says "Do NOT use external issue trackers" (we track in GitHub issues); and references docs/QUICKSTART.md and .beads/, neither of which exists in this repo. A checked-in AGENTS.md is exactly the file coding agents treat as authoritative, so this would actively instruct agents to violate the repo's no-commit/no-push constraints. Please drop it (along with the .dolt/ / *.db .gitignore lines that only make sense with beads) or replace it with a real project-specific AGENTS.md.
  • issue: slugIssueNumber over-matches descriptive numbers, so the CI guard can reject valid drafts. /^\d+-[a-z]+-?(\d+)-/ (dedup.ts:85) accepts ANY word-then-number: 10043-fix-500-errors-on-login.md → 500, 9200-support-2-level-hierarchy.md → 2 (reproduced on Node 22). When that spurious number differs from the frontmatter issueNumber, ciGuardReason flags a correct draft as mislinked and open-review-pr drops it from promotion. relink-issues.ts already solves this with a conventional-commit type list (fix|feat|perf|…) — reuse that (or the shared module) so the two parsers cannot diverge.

Smaller:

  • dedupeByIssueId (dedup.ts:153): drafts with a missing/non-scalar id all bucket under the shared '' key and get collapsed as duplicates of each other, silently deleting one. Unreachable in-pipeline today (schema validation requires id upstream), but the function is exported with no guard and the branch is untested.
  • Cross-domain canonical selection is lowest-PR-number only; domainFit is never consulted, so a weak-fit lower-PR draft wins the domain over a strong-fit higher-PR one. Worth a tiebreaker or at least a doc note.
  • reconcile's CI-guard/dedup buckets are structurally zero at the only wired call site: run-pipeline never invokes open-review-pr, and open-review-pr (which writes the CI guard: / duplicate of reasons) never calls reconcile. Either print a reconciliation at the end of open-review-pr too, or trim those buckets from the report.
  • reconcile throws on a valid-JSON skip line without a string reason (entry.reason.includes, reconcile.ts:50) — parseSkipLogLine casts JSON.parse without shape-checking, and because reportOutcome runs unconditionally, one corrupt audit line turns a fully successful batch into exit(1). One typeof guard plus a test.
  • When dedup adds source_prs[], rewriteFrontmatterOnDisk persists the fully normalized frontmatter, not a targeted insertion — key order/date quoting can reflow beyond the intended change. Cosmetic; it re-validates.
  • nitpick: stray // ponytail: marker at run-pipeline.ts:484.
  • nitpick: comment density — dedup.ts is ~48% comment lines and reconcile.ts ~57%, with @example blocks restating one-line bodies; this repo keeps comments minimal.

Hareet added a commit that referenced this pull request Jul 17, 2026
Per sugat009's review on #132: collapse 10 duplicate clusters (10036,
10038, 10037, 8985, 9065, 9241, 9835, 9264, 9426, 9601) to one memory
per issue, folding each PR's distinct content into the canonical with a
source_prs[] provenance array (schema.json gains the optional source_prs
definition, byte-identical to PR #138's). 15 collapsed files removed.

Suspect 9311 verified against cht-core: its PR body explicitly closes
issue #9241 ("Create API endpoint for getting people"), so the stored
key was correct; folded into the 9295 canonical as a second source PR.

Cross-domain dedup: issue #6543 is canonically authentication (multi-
facility user permissions), so 9094 (webapp display facet) is removed
here and will be folded into the authentication memory on #131.

Also: scrubbed classifier/reviewer process narrative from prose,
backfilled related_issues for the 9193/9237-9242 datasource family,
fixed the #9241 title drift. All 43 PR-to-issue mappings verified
against the live cht-core API (0 mismatches); validate-schema 92/92.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Hareet added a commit that referenced this pull request Jul 17, 2026
…ation)

Per sugat009's review on #131: collapse the #8868 backport pair
(8924+8933) into one memory with source_prs[]; drop 8843 (feat(na),
closes no tracked issue — #136 skip-and-flag policy).

Cross-domain dedup: fold the webapp display facet (PR #9094, moved from
the contacts seeder) into the #6543 canonical here (source_prs 9094 +
9126); drop 9204/9205/10222 whose issues (#9203/#9065/#9835) are
canonically owned by the contacts corpus — their PR refs get recorded
there in a follow-up commit on #132.

Suspect 9955 verified against cht-core: PR body explicitly closes #9735
(the SSO epic), so the stored key was already correct; the filename
token 9760 is a stale title scope (issue #9760 is owned by 9800's file).

Also: backfill related_issues across the SSO issue family (epic 9735 +
sub-issues), scrub reviewer/process narrative from 19 files, add the
optional source_prs schema definition (identical to #138 and #132).
All 39 PR-to-issue mappings verified against the live cht-core API
(0 mismatches); validate-schema 98/98; no duplicate issueNumbers.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Hareet added a commit that referenced this pull request Jul 17, 2026
Per sugat009's review on #120: collapse the 3 duplicate clusters to one
memory per issue with source_prs[] — backport pairs 10068 (10073+10082)
and 10225 (10230+10243), and the 10802 sibling fixes (10803+10811)
folded into the existing issue-keyed memory; 9559 likewise folded into
the existing 9467 RapidPro memory. 5 collapsed files removed.

Corpus fix for 10729: grounded the memory in the merged PR #10730
(source_prs added; Solution/Testing now attribute the shipped fix).

Domain fit: 8717 (conversation-UI navigation to the contact page)
honestly re-annotated domainFit: weak; 10853/10477 verified as genuine
pipeline code (transitions, message-utils) and stay strong.

Also: backfill related_issues (10442->10446 closing ref, 10729<->10802,
10802->10428), scrub reviewer/process narrative from 8 files, add the
optional source_prs schema definition (identical to #138/#132/#131).
All 17 PR-to-issue mappings verified against the live cht-core API
(0 mismatches); validate-schema 76/76; no duplicate issueNumbers.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Hareet added a commit that referenced this pull request Aug 5, 2026
…ne for review (#120)

* chore(memory): promote strong-fit messaging drafts for review

* fix(#135): relink id/issueNumber/issueUrl to real issues (metadata-only, messaging)

Deterministic relink via #129's relink-issues tool: drafts whose identity
keys recorded the merge PR now point at the resolved issue. Frontmatter
id/issueNumber/issueUrl lines only; bodies untouched.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(#135): collapse duplicate clusters + review fixes (messaging)

Per sugat009's review on #120: collapse the 3 duplicate clusters to one
memory per issue with source_prs[] — backport pairs 10068 (10073+10082)
and 10225 (10230+10243), and the 10802 sibling fixes (10803+10811)
folded into the existing issue-keyed memory; 9559 likewise folded into
the existing 9467 RapidPro memory. 5 collapsed files removed.

Corpus fix for 10729: grounded the memory in the merged PR #10730
(source_prs added; Solution/Testing now attribute the shipped fix).

Domain fit: 8717 (conversation-UI navigation to the contact page)
honestly re-annotated domainFit: weak; 10853/10477 verified as genuine
pipeline code (transitions, message-utils) and stay strong.

Also: backfill related_issues (10442->10446 closing ref, 10729<->10802,
10802->10428), scrub reviewer/process narrative from 8 files, add the
optional source_prs schema definition (identical to #138/#132/#131).
All 17 PR-to-issue mappings verified against the live cht-core API
(0 mismatches); validate-schema 76/76; no duplicate issueNumbers.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(#136): ground messaging drafts against cht-core source

Every factual claim in the seventeen drafts on this branch was checked against
the cht-core commit it was distilled from — word-bounded `git grep`,
`diff-tree --name-status`, `ls-tree`, and reading the hunks. 189 claims
confirmed; 63 corrections applied. Each was additionally re-checked by a second
pass instructed to refute it, and five proposed corrections were discarded that
way rather than shipped.

The recurring defect is not a wrong identifier — it is a correct identifier
wrapped in a wrong story:

10073 described an inbound Express/`req.body` double-parse. The file has no
Express handler and no `req` at all; it is outbound-only. The real bug was
`sendMessage` re-parsing the response body of its own POST, which
`@medic/couch-request` had already parsed, so every send silently produced no
state change. Title, summary, problem, root cause and Code Patterns all restated
accordingly, and the e2e spec described as added was modified.

10802 used `task.status` where the field is `task.state`, and presented #10811
as a sibling guard when its commit body reads "(cherry picked from commit
6a5867b)" — a byte-identical backport of #10803. The fabricated
`isDue()`/`due_date` snippet is removed.

10497 read `resolveMany` as fanning out to several recipients when it returns
the first that resolves. 4278 and 8492 stated the opposite of the code in Design
Choices, and asserted test coverage absent from their diffs. 9364 generalised a
narrow fix. 8717 described only additions when the commit renamed a spec away.
9467 named a member that does not exist on the object at the cited line.

Also corrected across the branch: file lists that lost A/M/D status, test paths
missing the `.spec` segment, and classifier scaffolding in 10868's rationale.

Held back deliberately: five proposed corrections that did not survive
re-checking, including one whose replacement would have relocated a throw to a
line unreachable in the failing scenario. Twenty-four claims remain
unverifiable — chiefly the legacy drafts that carry no source commit and the two
whose PR numbers appear nowhere in cht-core history. Nothing was edited on the
strength of a claim that could not be checked.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(#136): relink two hand-authored identities + anchors, cross-link the due_tasks pair (messaging)

- 4278 and 8492 keyed their identity to PR numbers — the round-1 defect
  class surviving in two pre-existing hand-authored drafts the relink
  tool never touched (it only reads machine frontmatter). Both prose
  bodies already named the real issues: 4278 -> #3738 (PR #4278's body:
  'Issue: #3738'), 8492 -> #8414 (PR title 'fix(#8414): sms gateway test
  flakiness'). id/issueNumber/issueUrl relinked accordingly.
- Both drafts also gain machine anchors (source_prs + the PR merge
  commit as source_sha: d88f2e256, 2c740238) so claim grounding can
  check them at their own trees instead of degrading to master-fallback
  guesses — d88f2e256's tree is where the draft's 2018-era paths are
  real, and 2c740238 touches exactly the two files the 8492 draft names.
- 10442 <-> 10802 both rework due_tasks.js state transitions but only
  10802 carried the back-reference; related_issues on 10442 now links
  cht-core-10802, making the in-batch pair symmetric (the same class
  flagged on the configuration batch's 9696/9727).

validate-schema: 76 passed, 0 failed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(#136): correct two residual 10802 claims caught by the anchored probes (messaging)

Once the source_prs fallback anchored 10802 at 6a5867bb, two claims the
July-27 grounding pass missed became checkable and failed:

- 'Filter tasks by both due_date and status fields' — neither field
  exists; the real comparison is the computed due value
  (task.due || task.timestamp || doc.reported_date) plus the task.state
  guard. Same fabrication family as the review-2 inline, one bullet over.
- 'Added a sentinel integration test (due-tasks.spec.js)' — the file
  pre-existed; 6a5867bb ADDS a 69-line case to it (M, not A).

Also aligned two prose 'status' field references to 'state'.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(#136): correct the RapidPro broadcasts endpoint spelling (9467, messaging)

The draft named an 'api/v2/broadcast' endpoint; the service posts to
'/api/v2/broadcasts.json' (api/src/services/rapidpro.js:92 at da4b50f7).
Caught by the probes on the second anchored run — the claim only became
checkable once the API resolver anchored this hand-authored draft.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(#136): round-3 review, the self-contradiction class, last PR-keys (messaging)

All 16 inline items plus three contradictions review did not reach and
the follow-ups agreed as in-scope.

THE CLASS BEHIND MOST OF IT. The grounding pass corrected the sections
that assert mechanism against code and left the interpretive ones
asserting what it had just disproved, so several drafts told two stories.
A coherence pass over all 22 drafts found six; review had named three.

- 10073: Domain Rationale still located the bug inbound while Root Cause
  and Code Patterns say the file is outbound-only and never sees req.body;
  the Related Issues gloss said 'request body'; techStack still listed
  express. All now agree the double-parse was of the send RESPONSE.
- 10729: Design Choices claimed existing unit tests covered the
  functionality; the grounded Testing section says neither fix is covered.
  Bullet dropped. parseArray's mechanism narrowed to what smsparser.js
  actually does - getParser returns undefined for a non-string message or
  an unrecognized Muvuku code, not merely because def is null.
- 4278: Problem opened with 'had no test coverage', which its own Root
  Cause refutes; the illustrative fence was a composite of two real
  helpers that appears nowhere (replaced with allMessageDocs verbatim);
  the invalid-content test claims are gone (the 365-line spec's only
  'invalid|error' match is a fixture field 'errors: []').
- 10802 (not in review): one sentence said the fix landed on master and
  5.2.x AND that 5.1.x is the only line carrying it. Reworded - each patch
  reaches a different set of lines.
- 8717 (not in review): Solution credited 'navigation logic in
  sender.component.ts' while Code Patterns says it injects no Router and
  gained only two accessors; routing is the declarative routerLink.
- 3406 (not in review): Code Patterns recommended compound view keys
  'emit([task.state, when], val)' AND string keys instead of array keys.
  The PR did the latter - it changed emit([task.state, when]) to
  emit(task.state) so consumers can ask for several states in one request,
  keeping the due date in the value as sending_due_date.

ACCURACY. 10802's Root Cause blamed an eventually-consistent view; the
view is keyed ['scheduled', due] and cannot return an already-transitioned
task. Replaced with the real mechanism: the view vouches for one task
while updateScheduledTasks iterates every scheduled_task matching on due
date alone. 10802's #10754 cross-reference is deleted (it is a cookie
bug). 9467 loses the 62,000-message figure, which belongs to #10428's
empty-message workaround, and time-scopes err?.statusCode (master now
reads err?.status). 10497 no longer calls the review feedback stylistic -
it included a normalizeRecipient redesign.

DRIFT. 4278's four 2018-era paths (pre api/src, pre-wdio protractor tree)
are time-scoped in one note; the polling pattern is the durable part.

IDENTITY. The last five PR-keyed drafts are re-keyed to their real issues
- 3406->3073, 4039->3627, 4374->4110, 6995->6532, 7105->6572 - each with
source_pr/source_prs and the PR's merge commit as source_sha, closing the
class at 7 of 7. 4374's reference to the re-keyed 4278 entry now points at
#3738. Nine PRs cited in Related Issues as though they were issues are
labelled 'PR #N'. Canonical source_pr added to the five drafts that
carried only source_prs, per the schema's own wording.

Gate: validate-schema 76 passed / 0 failed; verify-drafts --online 0
blocking / 0 warnings / 0 unverified; check-coherence 0 contradictions.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(#136): keep the five hand-authored drafts out of this PR (messaging)

Reverts 3406, 4039, 4374, 6995 and 7105 to their state on main. Anchoring
them (last commit) made their claims checkable for the first time and they
do not survive it: 19 ungrounded claims across the five, including seven
fabricated metric names in 7105 (monitoring.messaging.outgoing.state and
its .delivered/.failed/.total./.seven_days/.last_hundred siblings, plus
monitoring.sentinel.backlog - none exist at its anchor), handleCallback /
RAPIDPRO_URL / RAPIDPRO_TOKEN in 6995, three files 4374 names as touched
that its backport commit never touched, and shared-libs/messaging in 4039.
8 drift hits and three contradictions sit on top of that.

These are pre-existing defects that the re-key exposed rather than caused,
but fixing them means substantially rewriting five 2017-2021 drafts - and
7105 may belong dropped rather than corrected, the way 11021 was on the
configuration branch. That is its own review, not a rider on this one, and
the reviewer had already scoped these as follow-ups outside this diff.

So this PR goes back to exactly the 17 drafts under review plus schema.json.
The re-key, the anchors, and 4374's now-stale reference to the re-keyed 4278
entry all move to a dedicated follow-up PR.

Reverting 3406 also removes a contradiction this branch had introduced: the
Code Patterns rewrite there was correct about the view (it emits msg.uuid
and task.state, no compound key) but left Design Choices still claiming the
PR emits both key shapes.

Two fixes for the 17 that stay:

- 8492: Problem blamed 'inconsistent message state setup in test factories'
  while its own Root Cause says the cause was shared mutable fixture state,
  NOT the factory failing to set a state. Same section-scoped pattern the
  reviewer identified; reworded to describe the symptom instead.
- 9467: getOutgoingMessages is real but lives in api/src/services/messaging.js,
  which the draft never said. Naming it removes a misattribution a reader
  could draw and settles a probe artifact.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(#136): bump 10853 lastUpdated (messaging)

Changing the stamp is itself an edit, so the freshness check needs the
final value, not the date of the content change that prompted it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(#136): last two in-scope findings from the final gate (messaging)

- 8492: a second contradiction in the same draft. Root Cause says the bug
  was shared mutable fixture state, 'not the factory failing to set a
  state', while Design Choices credited the fix to 'proper state setup'.
  Reworded to what the fix actually buys: per-build task objects make the
  tests order-independent.
- 9467: two sentences were phrased so that a prose aside became a
  code-shaped claim probed at the wrong tree. 'current master reads
  err?.status === 400' is true of master and false at this draft's anchor,
  so a symbol-in-file probe at the anchor refutes a correct sentence; and
  quoting a whole logger.error statement cannot survive a word-bounded
  grep. Both now name the property and the call site instead of embedding
  the expression, which is also easier to read.

Both facts are unchanged and still verified: rapidpro.js:109 on master
tests status, and the logger.error call is at rapidpro.js:110 at the anchor.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(#136): three contradictions an independent audit found (messaging)

An independent verification pass found that my own round-3 sweep had
reproduced the very class it was fixing: correcting one section of a draft
and leaving its siblings asserting the disproved story.

10442 - the worst of it, and mine. Problem was rewritten to the real code
path; summary and Design Choices were not. Ground truth at 862f69a6^ is
'if (task.messages) { updatedTasks = true; utils.setTaskState(task,
'pending'); }' - a task WITH a messages array but an empty body was
promoted to pending, only a task with no messages array at all sat in
scheduled. So the summary's 'they sat in scheduled indefinitely' was false
for half the cases, and Design Choices' 'deployments keep leaving such
messages indefinitely scheduled' contradicted the Solution's own note that
leaving them in scheduled 'is itself a change'. All four sections now
describe one path, and Design Choices says what the default actually
changes rather than implying continuity.

10442 Related Issues - #10446 is 'Dont send empty messages', not 'failed/
invalid scheduled messages were not being cleared'. The gloss restated
#10428's concern. It survived the cross-reference audit because gloss and
title share the word 'messages', which is a live demonstration that one
shared content-word defeats word-disjointness in a messaging corpus.

10729 summary - 'causing fields to never match' is the pre-correction
silent-failure story, which the draft's own Problem section refutes: the
loop threw TypeError on item[0] and propagated out uncaught. Round 3 had
rewritten the second half of that sentence and left the first.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(#136): three narrative corrections from the independent audit (messaging)

- 9467: 'the pre-existing logger.error call that was moved above the new
  branch' - nothing moved the log. The diff shows the '// ignore error,
  sending the message will be retried later' COMMENT moving below it while
  the logger.error line stays as unchanged context. The resulting position
  was right, the motion was not.
- 8492: '#6995: RapidPro SMS gateway integration (related testing
  improvements)' - #6995 is 'Adds RapidPro as an SMS Gateway', a feature.
  The title gloss was fine; the relationship parenthetical was the
  mischaracterisation, and relationship parentheticals are exactly what
  the cross-reference audit exempts from checking.
- 9022: 'Added/updated ... and a Sentinel integration spec' read as though
  the integration spec were new. diff-tree at 2e1a05ff17 shows only
  tests/e2e/default/reports/sms-messages.wdio-spec.js added; the
  integration spec was modified (+160/-90), as were the two unit specs.
  Now says which single file was added.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs(#136): correct the revert rationale recorded in 620ea52 (messaging)

620ea52 justified keeping five hand-authored drafts out of this PR partly
on "seven fabricated metric names in 7105". That claim is wrong and an
independent audit caught it.

All seven exist at 7105's anchor 67626fff, as nested keys of the
monitoring response rather than as dotted tokens:

  git -C $CORE show 67626fff:api/src/services/monitoring.js \
    | grep -nE "backlog:|total:|seven_days:|last_hundred:"
  # :293  backlog: sentinelBacklog
  # :325  total: jsonV1.messaging.outgoing.state   <- the claimed rename
  # :326  seven_days: weeklyOutgoingMessageStatus
  # :327  last_hundred: lastHundredCounts

and that PR is what adds failed/delivered to the v1 state counters
(MESSAGE_QUEUE_STATUS_KEYS gains them; the parent had only due/scheduled/
muted). A dotted path like monitoring.messaging.outgoing.seven_days
describes the JSON shape and cannot grep as one token - the same
extraction artifact this branch correctly dismissed four times elsewhere
(sms.clear_failing_schedules, smsparser.parse, nepal-doit-sms,
getOutgoingMessages). I booked it as evidence instead.

So the "19 ungrounded claims" figure was inflated by artifacts of that
class. The decision to defer the five still holds, on evidence that does
survive checking:

- 6995 names RAPIDPRO_URL, RAPIDPRO_TOKEN and handleCallback; all three
  are zero-hit at e9e305d2, where credentials actually come from
  secureSettings.getCredentials('rapidpro:outgoing').
- 3406 contradicts itself: Code Patterns recommends compound view keys
  while also recommending string keys, and the PR emits only msg.uuid and
  task.state - no compound key at all.
- 4374 names three files as touched that its backport commit does not
  touch; 4039 names shared-libs/messaging, absent at its anchor.

Those are real defects in drafts that have never been anchored, and they
still deserve their own review rather than a rider on this one. But the
count was overstated, and the record should say so.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(#136): 9022 Problem overstated how narrow the old gate was (messaging)

Problem said the context was populated 'only when a shortcode id was
present on the report's fields', which the draft's own Root Cause refutes:
the gate was 'doc.patient_id || doc.fields?.patient_id', so a top-level id
worked too. What it never consulted was the hydrated doc.patient, which is
exactly what the fix adds ('|| doc.patient?.patient_id'). Problem now says
that. Found by one coherence pass of three - the sampling caveat in
practice.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(#136): bump 9022 lastUpdated, which the previous commit missed (messaging)

1d54af9 rewrote 9022's Problem section and left the stamp at 2026-07-30,
so the branch head failed the stale-timestamp check that 31c8d8c had
documented hours earlier. Second time this cycle after 9407, and for the
same reason both times: the stamp is set from the date of the change being
made, then a later commit to the same file moves 'last edited' past it.
'Touch the file, stamp it today' is the only version of the rule that
survives its own next commit.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
@alexosugo

alexosugo commented Aug 12, 2026 •

Copy link
Copy Markdown
Contributor Author

Updates

  • Made duplicate-draft promotion retry-safe: pending drafts are removed only after the canonical review PR is created.
  • Applied consolidated source_prs metadata only to the promoted copy, keeping pending drafts retryable if promotion fails.
  • Scoped reconciliation summaries to records written during the current invocation.
  • Preserved the source repository for provenance and related-issue links, including CHT Interoperability drafts.
  • Added explicit, unambiguous issue tokens for Conventional Commit titles, including breaking changes.

Verification

  • Focused regression coverage passes.
  • Lint and TypeScript build pass.
  • A live distillation of merged CHT Core PR #11331 produced a schema-valid draft with fully grounded file references.

@alexosugo
alexosugo requested a review from Hareet August 12, 2026 17:37
@alexosugo

Copy link
Copy Markdown
Contributor Author

@Hareet this is ready for another review.

@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.

Substantial work, and the core design is right. resolveRealIssue classifying a number before trusting it, with one hop from PR to sole closing issue, is the correct shape, and flagForHuman on zero resolved issues is the correct failure mode. Checked against cht-core PR 9039, whose title says fix(#9024) where 9024 is an unrelated PR closing nothing: the chain drops the ref and flags for a human instead of mislinking. issueEqualsSourcePr is structurally right too, catching the pre-R1 shape by construction rather than by pattern.

One blocking problem: the file name and the guard disagree by design, so the guard rejects drafts where resolution did its most valuable work. The rest are gaps, plus a merge-order note for #127.

Two comments are anchored a little away from the code they discuss (the scraper.ts base-branch gate and discoverDraftsByDomain), because those lines fall outside this diff's hunks; the real line numbers are in the comment text.

Comment thread src/scripts/distiller.ts
Comment thread src/scripts/open-review-pr.ts
Comment thread src/scripts/scraper.ts
Comment thread src/scripts/open-review-pr.ts
Comment thread src/scripts/dedup.ts
Comment thread src/scripts/dedup.ts
Comment thread src/scripts/run-pipeline.ts Outdated
Comment thread src/scripts/scraper.ts Fixed
@alexosugo

Copy link
Copy Markdown
Contributor Author

@sugat009 thanks for the thorough review — every item is addressed and validated, and all CI checks (including the SonarCloud gate) are green on e2fd52f.

Blocking:

  • Filename vs guard: draft filenames now derive from the resolved issueNumber; a title-versus-resolution mismatch is a warn audit entry, not a rejection. Validated against your simulation table and live cht-core PR 9039 — details on the thread.
  • CI wiring: npm run check-pending now runs ciGuardReason + schema validation over _pending in unit_tests.yml on every PR.

Non-blocking:

Merge order as you suggested: this PR first, #127 rebases on top. Ready for another look when you have time.

@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.

Re-review at e2fd52f, full pass over all 23 changed files. This time with the pipeline model corrected: _pending/ is the staging area, and open-review-pr promotes drafts into domains/<domain>/issues/.

Retractions first. My earlier review was built against 0e5cd96 and I posted it hours after you had already pushed fixes. Four of those seven findings were fixed before I posted:

  • filename now derives from the resolved issue number, not the title token (235ca7d)
  • pending-draft guard wired into CI (6a7307c)
  • PRs merged into non-default branches are excluded (a8deae2)
  • source_prs omitted when empty (b8dddfd)

On the first of those: you shipped the warn-don't-reject shape instead of rejecting a title/issue mismatch. That is the better design and it is what we wanted. Sorry for the wasted round.

What is left. Two blockers in this PR's own code, both inline. Separately, three blockers already on main that this PR does not cause, but that stop its new CI guard from ever receiving input. I list those here because they change how you should read the guard, not because they are yours to fix in this PR.

Blockers already on main (not caused by this PR)

None of the three lives in a file this PR touches, so they are here rather than inline.

1. .husky/pre-commit fails the nightly commit step, and the hook does run in CI. package.json:14 is "postinstall": "husky install", and npm ci runs postinstall. husky 8.0.3's install() skips only when HUSKY=0, or when git or .git is unavailable, neither of which applies on a runner. .husky/pre-commit:4-9 then rejects any commit while the branch is master or main. The job log of run 31673806379 says, verbatim: > husky install / husky - Git hooks installed, then at step 7 You can't commit directly to master/main branch / husky - pre-commit hook exited with code 1 (error).

The clean way to state the scale: across the workflow's entire history, all 50 runs since it landed in c3b4bf6 on 2026-06-24, the scheduled pipeline has never once committed a draft. All 26 failed runs died at "Commit knowledge drafts" on this hook. All 24 green runs found 0 PRs and staged nothing, so the commit was skipped and the push was a no-op.

Smallest fix: env: { HUSKY: '0' } on that step (run-pipeline.yml:57). Keep the hook, it is useful locally.

2. Disabling the hook only moves the failure to git push (run-pipeline.yml:63). Ruleset 15146469 ("Main Branch Rule") is active on ~DEFAULT_BRANCH, has an empty bypass_actors, and carries a pull_request rule. Classic protection is off (branches/main/protection returns 404), which is why main looks unprotected. The other ruleset you will see in the list, 10342826 ("Main"), is disabled and not relevant.

This is not just configured, it is demonstrably evaluated on ref updates to refs/heads/main: the rule suite for the last such update (3642933719, b2571b9 to 041f132) evaluated pull_request, required_status_checks, non_fast_forward and deletion. It passed, because that update arrived through a merged PR. A direct push would not satisfy the pull_request rule. The same ruleset also requires the "Build, lint, and test" and "Lint PR title" checks, and the pipeline commits with [skip ci], so those two contexts would never report on a directly pushed commit either. I should be straight that no run has ever reached git push with a real commit, so this is the expected outcome rather than an observed one.

3. The nightly has no LLM key, so it cannot produce a draft. In run 31673806379, two of the three PRs died at the filter stage with filter: flag-for-human - LLM triage unavailable: no API key set (OPENROUTER_API_KEY or ANTHROPIC_API_KEY), and the third (#11319) passed the deterministic filter (filter: distill - Shared library change affecting multiple consumers), reached distilling..., and died there with distill: flag-for-human - Distill LLM unavailable: no API key set. repos/medic/cht-agent/actions/secrets returns total_count: 0, and the 3 org secrets visible to this repo are unrelated (BrowserStack, build history, Docker Hub), so secrets.OPENROUTER_API_KEY resolves to empty.

Across all 50 runs I checked every job log (none expired): 8 PRs reached the distill stage, all 8 flagged for human on the missing key, and 0 drafts were ever written. Note distiller.ts supports ANTHROPIC_API_KEY and the error message advertises it, but the workflow wires only OPENROUTER_API_KEY, so an Anthropic-only secret would still leave CI keyless.

Those three together mean agent-memory/_pending/ has never held a committed .md on main (only 8 .gitkeep files). So check-pending validates an empty directory today. The guard itself is correct. It just has no input yet, and it cannot reach the three mislink defects already sitting in domains/. That was the agreed scope, so this is fine.

Open questions

I am not confident enough to assert these, so I am asking rather than filing them.

  1. Release branches. The gate treats any non-default base as unshipped. cht-core PR 11137 (fix(#11128): adds limit to Nouveau indexed strings) merged into 5.1.x on 2026-06-04; its merge commit f8f58ce is contained in tags 5.1.3, 5.1.3-beta.1 and 5.1.3-beta.2 and never reached master. That is real released work the gate excludes. Intended, or should 5.1.x-style release branches be allowed?
  2. Long-lived feature branches. 10 of the newest 100 merged cht-core PRs went into 10707-dmp-2026-enhance-bikram-sambat-support-in-cht, whose umbrella PR 11211 is still open. The sharper case is one that already landed: the 10224-ui-extensions umbrella merged to master via PR 11050 on 2026-06-23 and its branch is deleted, yet PRs 11105 and 10909 still report baseRefName: 10224-ui-extensions, so the gate rejects them permanently even though their work is on master. Acceptable, or should the corpus pick such PRs up once the umbrella lands?
  3. Epic semantics. Is dropping non-canonical epic members intended, or should a genuine multi-PR epic keep one entry per PR? See the inline comment on dedup.ts:129.
  4. _skipped.ndjson truncation. This PR empties the file (7 lines to 0 bytes). All 7 removed rows are prNumber: 1 with reason "LLM triage skipped", so I read it as deliberate cleanup. Confirming, because #127 made the same truncation in ba6ef06 and then reverted it in 766f008.
  5. --repo and the schema. run-pipeline accepts any --repo owner/repo and buildFrontmatter derives id/issueUrl from it, while schema.json permits only medic/cht-core and medic/cht-interoperability. Should the CLI reject other repos up front?
  6. Merge order with #127. This is not a risk, it is already a fact: git merge-tree on the two heads conflicts today, with src/scripts/run-pipeline.ts the only conflicted file and 5 conflict regions, one of them exactly the auditOffset/BatchState initialization in runPipeline. #127 changes that file by +58/-18. Whoever merges second must re-verify that the run-scoping of _skipped.ndjson survives the resolution.
  7. Is a direct push to main the intended design at all? Ruleset 15146469 requires 1 approving review, and this repo already has open-review-pr.ts. Routing the nightly through a PR would make both the hook and the ruleset non-issues.

Comment thread src/scripts/scraper.ts Outdated
Comment thread src/scripts/open-review-pr.ts Outdated
Comment thread src/scripts/dedup.ts
continue;
}
const ranked = [...group].sort((a, b) => {
const fit = (b.frontmatter.domainFit === 'strong' ? 1 : 0) - (a.frontmatter.domainFit === 'strong' ? 1 : 0);

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.

question (blocking on the answer, not the code): Epics and backports are keyed identically, and members are dropped rather than merged.

The module docstring (:11-13) names backport cherry-picks, multi-PR epics and independent domain promotions together. For a backport cluster, collapsing is exactly right. On the 10792 cluster in the corpus today (three drafts, all in data-sync), this code keeps 10793, and cht-core confirms that is the master fix (97e3d45, an ancestor of origin/master), while 10798 (5.0.x) and 10799 (5.1.x) are release-branch backports outside master's ancestry. Correct member, no argument.

For an epic it is the opposite. The comparator reads only the integer from source_pr (:131-133), so the tiebreak is lowest-numbered, i.e. earliest-opened, not earliest-merged and not mainline-vs-backport. That usually selects the least representative fragment of an epic. And the survivor's source_prs then asserts provenance over content that was deleted rather than merged.

Two things follow, both yours to decide:

  • Is dropping epic members intended, or should an epic keep one entry per PR (or be flagged for a human to merge)? A cheap discriminator is whether members' relatedFiles overlap: a backport cluster's do, an epic's do not.
  • If the tiebreak's intent is "prefer the mainline fix over backports", PR number cannot express that: a backport opened before the master PR would win. Should it prefer the draft whose source PR merged into the default branch instead?

Related: dedup.spec.ts:161-171 is titled "collapses a multi-PR epic" but uses 10792/10793/10798/10799, which cht-core shows is a backport cluster (one Type: Bug issue, one master fix, two release-branch backports). The fixture also places them in tasks-and-targets, while the three real drafts are in data-sync. Was an epic fixture intended?

let pushed = false;
try {
const addPaths = stageDrafts(validDrafts, path.join(domainsDir, domain, 'issues'));
const addPaths = stageDrafts(validDrafts, path.join(domainsDir, domain, 'issues'), opts.frontmatterByPath);

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.

issue (non-blocking): The promote commit never records the _pending deletions, so promotion is not idempotent.

git add (:459) receives only the domains/ copies from stageDrafts, and git commit (:460-461) has no -a. The _pending originals are removed by fs.unlinkSync at :473 with no subsequent git add. The only git add agent-memory/_pending/ anywhere in the tree is run-pipeline.yml:61, which belongs to the pipeline workflow and runs in a different job.

promoteDomain's add/commit/push/unlink shape is pre-existing on main, unchanged here apart from the frontmatterByPath threading. I am flagging it because this PR adds a second deleter, finalizeDedupDrops (:352-366, unlink at :360), which is new, so the uncommitted-deletion surface doubles.

The consequence is conditional, so stating it carefully: it needs _pending drafts to actually be tracked on main, which the CI path at run-pipeline.yml:61 would do but which has never yet happened. Once it does, a merged promote PR leaves the draft in _pending on main, any clone reflecting origin/main rediscovers it, and re-promotion stages nothing if every draft in that domain is already byte-identical on main. Then git commit exits 1 and promoteDomainSafely records failed, so that domain never drains. If even one draft in the domain is new or differs, the commit succeeds and the stale one is simply re-proposed. Note the recorded error reads Command failed: git commit -m feat(memory): promote ...; git's own "nothing to commit" text goes to stdout and is not captured.

Two notes so this is not overstated. I checked whether this already caused the 10792 triple and it did not: all three files arrived in the single commit fdf4af2, so that is three PRs resolving to one issue in one run. Second, :473 sits inside the try whose catch (:476-478) does if (pushed) deleteRemoteBranch(...), so an unlinkSync failure after gh pr create would delete the branch of the PR just created. Also pre-existing, also worth a guard.

Cleanest fix: stage the _pending paths as deletions on the promote branch so one PR both adds domains/ and drains _pending, and drop :473.

Comment thread src/scripts/reconcile.ts

function bucket(reason: unknown): keyof Omit<ReconciliationSummary, 'total'> {
if (typeof reason !== 'string') return 'otherFlags';
if (reason.includes('CI guard:')) return 'ciGuardRejections';

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.

issue (non-blocking): The buckets match free text, and one of them counts successes as failures.

bucket() keys on reason.includes('CI guard:') (:30) and reason.includes('duplicate of') (:31). Two consequences:

  • formatReconciliation labels the total "N flagged/skipped". A decision: 'warn' row is written for a draft that was successfully created (distiller.ts:437), so a good run reports created drafts inside that flagged total. Suggest excluding warn, giving it its own bucket, or renaming the label.
  • The LLM's own triage reason reaches the same log verbatim, when the triage decision is not distill (filter.ts:302), so a model that writes the words "duplicate of" in prose would be counted under dedupCollapses. Keying on decision plus a structured field would be sturdier than substring matching.

Also worth knowing: at run-pipeline's call site neither the CI guard nor dedup runs, so those two buckets have no producer there and read as zero except via exactly that free-text collision. The line is only meaningful from open-review-pr.

Comment thread src/scripts/distiller.ts
if (titleIssue !== null && titleIssue !== frontmatter.issueNumber) {
await logWarning(
pr.prNumber,
`title says #${titleIssue} but resolution chose #${frontmatter.issueNumber} — verify linkage`,

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.

issue (non-blocking): The title-vs-resolution warning never reaches a human-facing surface.

The mechanism is tested, so this is not about correctness: distiller.spec.ts asserts a warn entry exists and that its reason includes both #9024 and #9023, with a negative test for the agreeing case. The gap is downstream. The warning goes only to _skipped.ndjson, and nothing surfaces it to the person reviewing the draft: not the draft frontmatter, not buildPRBody, not the run summary except as an anonymous otherFlags count.

Suggest carrying it into the draft frontmatter (e.g. linkage_warning:) so buildPRBody can list it on the promote PR. That is where a human can actually act on it.

One aside on that test's fixture, since it names the 9039 shape in a comment: it stubs linkedIssues: [{ number: 9023 }]. The real cht-core PR 9039 never reaches that state. It has no closing refs, its title token #9024 is itself a PR whose own closing refs are empty, and the body's bare #9023 has no fixes/closes keyword, so linkedIssues comes back empty and distillPR returns flag-for-human at :502-503. The fixture is a fine unit test of the warning, but 9039 is not a live example of it, which is worth knowing if that shape is ever used to argue coverage.

Comment thread src/scripts/scraper.ts
const raw = execFileSync('gh', ['repo', 'view', repo, '--json', 'defaultBranchRef'], EXEC_OPTS); // NOSONAR typescript:S4036 -- gh resolves from PATH like every other gh call here
const parsed = JSON.parse(raw) as { defaultBranchRef?: { name?: string } };
name = parsed.defaultBranchRef?.name ?? null;
} catch {

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.

issue (non-blocking): A transient gh failure is cached as a permanent null, silently disabling the gate for the rest of the process.

catch { name = null } then defaultBranchCache.set(repo, name). Failing open on one lookup is the right call and the docstring says so. Caching that failure is not: one rate-limited gh repo view turns the base-branch gate off for every remaining PR in the batch, with no log line, and nothing resets the cache between PRs.

Suggest caching only successful lookups, plus a console.warn on failure.

Comment thread src/scripts/distiller.ts
}

CONSTRAINTS:
- "relatedFiles" and "entities" MUST be chosen only from the Files changed list above. Do not infer, guess, or invent paths. If a file is not in that list, do not include it.

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.

nitpick (non-blocking): DRAFT_SHAPE was not tightened along with the inline block.

:227 says relatedFiles and entities must come only from the Files changed list, and the inline spec carries that for both fields (:214 and :221). But DRAFT_SHAPE (:88-109) still describes entities as "<file or module path>" at :99 and relatedFiles loosely at :106, and createStructuredCliChain(draftSchema, DRAFT_SHAPE) (:141) sends it on the claude-cli path. structured-cli.ts:52-55 appends the shape after the whole buildPrompt output, so the loose wording is the last field-level spec the model sees, with only the provider's own JSON-only trailer (claude-cli.ts:368) behind it.

Scoping this so it does not over-reach: the broader wording in schema.json:175 and TEMPLATE.md:98 looks deliberate, not stale, because reconcile.ts:12-13 and :52-54 (added in this PR) explicitly allow entities to name a module or concept rather than a literal path, and hallucinationRate is reporting rather than a gate. DRAFT_SHAPE is the one clear divergence.

run: npm run validate-schema

- name: CI guard pending drafts
run: npm run check-pending

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.

note (non-blocking): Right guard, right place. One correction to how I described its reach, and one real gap.

I want to be accurate about the reach, because it is better than I first thought: check-pending scans the checked-out working tree (check-pending.ts:18, :63-67, relative to REPO_ROOT), not the PR diff. actions/checkout@v6 on a pull_request event checks out the merge ref, so any draft sitting in _pending/ on main is present in every PR's tree and is inspected. Promote PRs are covered too, since promoteDomain only unlinks the originals locally after the PR exists.

The real gap is timing: this workflow has no push trigger, and the pipeline's commit carries [skip ci] (run-pipeline.yml:62). So the guard never runs on the commit that adds a draft. A bad draft would fail the CI of the next unrelated PR instead of failing at the point of landing. Note a push trigger alone would not close that, because [skip ci] would still suppress it. Was [skip ci] deliberate there?

Also, check-pending currently scans a directory holding 8 .gitkeep files and zero drafts, so the step passes trivially today. Neither point is a defect in this PR.

Comment thread src/scripts/scraper.ts
* Strips HTML comments (e.g. CHT's PHI-warning template boilerplate) from a PR
* or issue body before it's truncated downstream. Un-stripped boilerplate can
* consume the entire truncation budget before the real content — see cht-core
* issue #10912, where a ~246-char PHI comment pushed the root-cause sentence

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.

note (non-blocking): Consolidated smaller items, anchored here because the first few concern this function. Not worth their own threads.

  • scraper.spec.ts:178 (stub :179-181, sole assertion :182) cannot fail. Its guard throw is swallowed by defaultBranch's own catch (scraper.ts:268-270) and the fail-open at :285. I checked by mutation: deleting scraper.ts:283 (if (!base) return;) leaves the suite at 1081 passing, unchanged from baseline, so the test does not detect the regression it names.
  • stripBoilerplate: both production call sites are untested. Neutering either scraper.ts:90 (issue body) or :357 (PR body) leaves the suite at 1081 passing. Only the function itself is covered (scraper.spec.ts:1023-1040). It also removes comments with no separator, so adjacent words glue together, and scraper.spec.ts:1039 locks that in by asserting '<!-- a -->Keep this<!-- b -->and this<!-- c -->' becomes 'Keep thisand this'.
  • The ~246-char figure in this rationale (:29) does not match the issue it cites. The leading PHI comment in cht-core #10912 measures 343 chars, and the bug-report template has been 343 at every revision that carries it. The rationale otherwise stands: against ISSUE_BODY_LIMIT = 500 a 343-char boilerplate comment really does push the root-cause sentence out of budget. Where does 246 come from?
  • Only review bodies currently reach the distiller prompt un-stripped (distiller.ts:166-170, interpolated at :200), so that is the live prompt-contamination path and worth stripping. Issue comments reach no prompt at all: LinkedIssue.comments is written at scraper.ts:89-90 and read nowhere in src/, since buildPrompt uses only i.body. Is that field meant to be dead? If it is meant to feed the prompt, it needs both wiring and stripping.
  • pipeline.ts:38: was repo?: string left optional deliberately to avoid touching ScrapedPR fixtures? scrapePR always sets it and distiller.ts:316 falls back with ??.
  • scraper.ts:265 carries the NOSONAR S4036 marker, and it is one of six call sites in this file that shell out to gh: five direct execFileSync (:76, :159, :198, :236, :265) and one through the ghExec wrapper (:87). Fine if the Sonar gate is new-code-only, otherwise the siblings will surface eventually.
  • uniqueBranchName (open-review-pr.ts:207) probes only local refs, and executeApply fetches only origin main (:531), so remote-tracking refs are stale too. A promote from a fresh clone, or one whose local branch was pruned, can collide with a branch that already exists on origin. No workflow runs open-review-pr today, so this is a local hazard for now.
  • rewriteFrontmatterOnDisk can silently no-op, because hasFrontmatter (schema-utils.ts:50-53) strips a leading BOM while the rewrite regex at :295 is ^-anchored and does not, so a BOM-prefixed draft is admitted and rewritten to itself. And on a draft that already carries source_prs it injects a duplicate key, which is not cosmetic: I verified against the installed js-yaml 4.1.1 that it throws duplicated mapping key, and gray-matter 4.0.3 propagates it as a YAMLException, so the promoted file becomes unparseable. Nothing in the pipeline writes source_prs into _pending today, so this needs a hand-authored draft, and stageDrafts triggers on source_prs !== undefined alone (:423), with no dedup collapse required. Can a draft ever arrive there already carrying the key?
  • A promotion that fails at stageDrafts/git add/git commit (open-review-pr.ts:458-461) leaves copies under agent-memory/domains/ in the operator's tree, staged rather than untracked if git add succeeded. If push or gh pr create fails instead, the copies are already committed and the residue is an abandoned local branch. Nothing cleans up either: the catch (:476-478) and the finally (:537-540) touch neither domains/ nor the local branch.
  • source_prs behaves correctly (every non-null ref in the group is kept), but there is a coverage gap and a provenance oddity: when a strong-fit draft with no source_pr of its own wins the sort, it is stamped with a source_prs list that does not include itself. dedup.spec.ts:193-201 is the only mixed-group test and it asserts nothing about source_prs; its canonical does carry a source_pr, so the canonical-without-source_pr case is untested anywhere in the file.
  • dedup.ts:19 imports its filename grammar from relink-issues.ts, so the CI guard at unit_tests.yml:34-35 now depends on a repair script. relink-issues.ts has an npm script (package.json:25) but appears in no workflow. Consider moving FILENAME_TOKEN_RE to a shared module.
  • The new <pr>-issue-<n>-<slug>.md convention is a third grammar. On main the 110 drafts split 52 <number>-<slug>.md and 58 <pr>-<type><issue>-<slug>.md, with 0 in the new shape, while TEMPLATE.md:7 still documents <issue-number>-<short-slug>.md.
  • docs/memory-seeding-runbook.md is stale on four points, three of them new here: :151-152 (force overwrite / same output path), :160 (_skipped.ndjson described as skip/flag-for-human only, now also warn), and :7-9 (merged PRs, now default-branch-only). The pre-existing one is :96 (a "2g memory limit" vs the compose default of 6g). :161 omitting npm run check-pending is an omission rather than a contradiction.
  • #146 is half addressed. Done: the polluting call site (filter.spec.ts:229/:237) and the purge of all 7 fake prNumber: 1 rows. Not done: logPath on the four option-less filterPR calls (:190, :203, :216, the three DISTILL: cases, plus :245), and the NODE_ENV=test guard. Also the PR references #146 nowhere, so it will not auto-close. Do you want the remainder here or left on the issue?
  • The exclusion message interpolates a branch name into a string that recordWorkerError later keyword-matches for rate limits. Currently harmless: 0 of cht-core's 916 remote branch names collide, and only 5 of the 14 keywords are reachable from a refname at all, since refnames cannot contain spaces. The realistic path is a stacked PR whose branch is slugged from an issue like #10705 or #11090, where ...-rate-limiter-... matches /rate[\s_-]?limit/. Brittle by construction rather than broken.
  • Scope: 30 commits, 23 changed files, and 11 commits with no ticket reference. The PR body declares two issues, #135 and #136. Was the #135 relink-issues work meant to ship here, or split?

ciGuardReason only ran inside open-review-pr, so nothing in CI enforced
it. New check-pending script validates every agent-memory/_pending draft
against schema.json and the guard; unit_tests.yml runs it on every PR.
The merge gate read mergedAt alone, so a PR merged into a later-abandoned
feature branch still distilled as shipped work. The scraper now fetches
baseRefName and rejects PRs whose base is not the repo default branch
(resolved once per repo via gh repo view, failing open on lookup errors).
A dedup group with no source_pr members wrote a valueless source_prs: key
(schema-invalid YAML on the promote PR). Also: dedup docstrings updated to
the domainFit-first canonical rule and the stale-file role of the slug
check, doubled /** opener removed, stray review marker dropped.
check-pending restructured into small pure helpers (S3776); the base-branch
gate extracted to assertMergedIntoDefaultBranch (S3776); S4036 suppressed on
the gh default-branch lookup, which resolves gh from PATH like every other
gh call in the module.
@alexosugo

alexosugo commented Aug 27, 2026 •

Copy link
Copy Markdown
Contributor Author

Design decisions for the memory pipeline

The review threads on this PR raised questions that are product decisions rather than code defects. I have written one decision per question in docs/memory-pipeline-decisions.md (now committed on this branch, so you can comment on individual lines under Files changed). Each decision is a proposal until a decider approves or objects.

Please react to the numbered decisions directly instead of the open threads. The full text follows.

docs/memory-pipeline-decisions.md (click to expand)

Memory pipeline: design decisions

Status: DRAFT — awaiting sign-off (posted to PR #138 on 2026-08-27)
Deciders: @alexosugo, @sugat009, @Hareet
Context: PR #138 review (rounds 1–3). The review surfaced questions that are
product decisions, not code defects. This doc records one decision per question so
future reviews argue against a written spec instead of re-negotiating in threads.
Each decision below is a proposal until a decider objects or approves.

A fact that frames everything: the nightly pipeline has never produced a draft in
production. All 50 scheduled runs since 2026-06-24 either found no PRs or died on
a missing LLM key, and the commit step has never succeeded (husky hook). Decisions
D1 and D2 exist to get one real end-to-end run, because until then every review
argues hypotheticals.


D1. The nightly promotes drafts through a PR, not a direct push to main

(Sugat Q7; dissolves the husky and ruleset blockers)

Decision: Replace the direct git commit && git push in run-pipeline.yml
with the existing promotion path: commit drafts to a branch and open a PR
(open-review-pr.ts already implements branch + PR creation). A human merges it.

Rationale:

  • Ruleset 15146469 requires a PR with 1 approving review for main, with no
    bypass actors. A direct push can never succeed. Working around the ruleset
    (bypass actor for the bot) weakens the protection for no gain.
  • The .husky/pre-commit hook that blocks the commit step becomes a non-issue on
    a branch; no HUSKY=0 workaround needed.
  • Machine-written knowledge should get a human glance before it lands. The repo
    already believes this: promotion from _pending to domains/ goes through a
    review PR by design.

Consequences:

  • [skip ci] on the pipeline commit is removed; the check-pending guard runs
    on the draft PR — closing the "guard never runs on the commit that adds a
    draft" gap (Sugat's note on unit_tests.yml).
  • The nightly needs permissions: pull-requests: write and a branch-naming
    convention (memory/nightly-<date>), plus dedup of an already-open nightly PR
    (append to the existing branch rather than opening a second PR).

D2. CI gets OPENROUTER_API_KEY; the workflow also wires ANTHROPIC_API_KEY

Decision: Add the repo secret and pass both env vars in run-pipeline.yml,
since distiller.ts supports both and the error message advertises both.
Owner: whoever holds repo admin (secret cannot be added from a PR).

D3. Base-branch gate: exclusion is a skip, never an error

(Blocker on scraper.ts; fix agreed in review, recorded here as the contract)

Decision: A PR merged into a non-default branch is a normal, auditable
exclusion. It must write a _skipped.ndjson row and leave the run's exit code
untouched. Implementation: carry baseRefName on ScrapedPR and decide in the
filter (checkSkipRules), not by throwing from the scraper. PR #N is not merged moves to the same channel. A pipeline-level test asserts
failures === 0 plus one appended skip row for a batch containing an excluded PR.

Contract: an exclusion the design intends is never allowed to fail the run
or to skip the audit log. Anything that increments failures must be an actual
malfunction (gh failure, corrupt JSON, rate limit).

D4. Release branches (5.1.x style) are IN scope; the gate allows them

(Sugat Q1)

Decision: The gate accepts the default branch plus branches matching
/^\d+\.\d+\.x$/. Everything else is skipped (per D3).

Rationale: cht-core PR 11137 merged into 5.1.x, shipped in tags 5.1.3+,
and never reached master. That is real released work; excluding it loses
knowledge the corpus exists to hold. The backport-duplication risk is already
handled: dedup keys on issue id and keeps the mainline fix, so a master fix
plus its release-branch backports collapse to one entry.

Trade-off accepted: a release-branch PR whose fix later also lands on
master produces a transient duplicate until dedup collapses it.

D5. Long-lived feature branches stay OUT of scope; the umbrella PR represents them

(Sugat Q2)

Decision: PRs based on feature branches (e.g. 10224-ui-extensions,
10707-dmp-2026-...) are skipped permanently, even after the umbrella merges.
The umbrella PR itself (merged into master) is scraped and distilled, and its
linked epic issue carries the knowledge entry.

Rationale: member PRs describe intermediate states; the umbrella carries the
shipped result. Re-admitting members once the umbrella lands would need
merge-ancestry tracking for deleted branches — real complexity for marginal
knowledge. Revisit only if a distilled umbrella proves too coarse in practice.

D6. Dedup never destroys distilled content

(Blocker on open-review-pr.ts)

Decision: finalizeDedupDrops moves dropped drafts to
agent-memory/_pending/_collapsed/<domain>/ instead of unlinkSync. The audit
row keeps the dropped draft's title. check-pending and promotion ignore
_collapsed/. A human can merge content from _collapsed/ by hand and delete it.

Rationale: for backport clusters the drop loses nothing of value; for epics
it deletes disjoint work (cht-core issue 6543: four PRs, file sets overlapping on
exactly one file). Since the pipeline cannot yet tell the two shapes apart (D7),
it must not delete either.

D7. Epic vs backport discrimination is deferred to #154; interim rule below

(Sugat Q3, the blocking-on-the-answer question on dedup.ts)

Decision (interim):

  • Dedup continues to collapse same-issue groups to one canonical entry.
  • The tiebreak changes from lowest PR number to default-branch base first,
    then lowest PR number
    . "Prefer the mainline fix" is the actual intent, and
    PR number cannot express it (a backport opened before the master PR would win).
    baseRefName is available once D3 lands.
  • When a group's members have effectively disjoint relatedFiles (pairwise
    overlap of at most one file), the group is flagged for human review instead of
    silently collapsed. That is the cheap epic detector Sugat proposed.
  • Full merge-per-epic semantics (one entry per PR, or LLM-merged single entry)
    is dedupeByIssueId: distinguish multi-PR epics from backport cherry-picks #154's scope, informed by real corpus data after D1/D2 produce some.
  • The dedup.spec.ts fixture titled "collapses a multi-PR epic" is renamed: the
    10792/10793/10798/10799 cluster is a backport cluster in cht-core, not an epic,
    and the real drafts live in data-sync, not tasks-and-targets. A true epic
    fixture (6543 shape) is added for the flag-for-human path.

D8. run-pipeline rejects repos outside the schema enum up front

(Sugat Q5)

Decision: --repo values other than medic/cht-core and
medic/cht-interoperability fail fast with a clear error, before any gh call.
The schema stays the single source of the allowed list. Widening the corpus to a
new repo is a schema change plus taxonomy review, not a CLI flag.

D9. Merge order: #138 lands first; #127 rebases

(Sugat Q6; already agreed in threads, recorded so it stops being re-asked)

Decision: #138 merges first. #127 rebases on top and its author re-verifies
that the run-scoping of _skipped.ndjson survives the resolution of the five
conflict regions in run-pipeline.ts (one is exactly the auditOffset/
BatchState initialization).

D10. _skipped.ndjson truncation in #138 was deliberate

(Sugat Q4)

Decision: Confirmed. All 7 removed rows were prNumber: 1 test pollution
("LLM triage skipped") written by a spec that ran without a logPath (#146).
The remainder of #146 (pass logPath in the four option-less filterPR spec
calls, NODE_ENV=test guard) stays on #146 and does not grow #138.

Post-rebase note (2026-08-27): #158 landed on main and gitignores
agent-memory/_skipped.ndjson. After the rebase the file is no longer in the #138
diff at all, so the truncation question is closed by main, not by this PR.

D11. Scope discipline for #138 and after

Decision: #138 takes only the D3 and D6 fixes (its two in-PR blockers) plus
the D7 interim tiebreak if reviewers want it in the same PR; otherwise D7 ships
with #154. Everything else here lands as follow-up PRs referencing this doc:
D1+D2 (pipeline-to-PR), D4 (release-branch gate), D5 (no change needed — it is
the current behaviour, kept), D8 (CLI guard). Non-blocking review findings
(reconcile buckets, warn surfacing via linkage_warning: frontmatter, transient
defaultBranch cache, stale runbook lines) become small issues, not #138 commits.

Validation practice (applies to all of the above): every behavioural change
gets a test at the pipeline boundary, not only the unit boundary — the D3 test
shape (failures === 0 + audit row) is the model. Unit tests prove mechanisms;
pipeline tests prove the mechanism is wired to the right channel.


Open items this doc does NOT decide

@alexosugo
alexosugo force-pushed the feat/136-memory-pipeline-feedback branch from e2fd52f to 0380d58 Compare August 27, 2026 08:40
@alexosugo

alexosugo commented Aug 27, 2026 •

Copy link
Copy Markdown
Contributor Author

Rebased on main at 2311664. Conflict in test/scripts/filter.spec.ts resolved as @sugat009 described (kept { logPath, skipLlm: true }). agent-memory/_skipped.ndjson is gitignored by #158, so it left the diff. Locally: npm run lint, tsc, npm run check-pending, and 1100 tests pass.

Scope (D11)

I diffed the PR against the decisions doc. The PR contains no code for D1, D2, D4, D5, D7 or D8. The only work left is the two in-PR blockers:

Decision Change Test
D3 baseRefName on ScrapedPR; skip rule in checkSkipRules; PR #N is not merged moves to the same channel; scraper stops throwing runPipeline batch with one excluded PR: failures === 0 and one appended skip row
D6 finalizeDedupDrops moves drops to _pending/_collapsed/<domain>/; check-pending and promotion ignore _collapsed/ dropped file exists under _collapsed/, audit row carries its title

I recommend not splitting the existing 30 commits. Sugat re-reviewed all 23 files at e2fd52f and named exactly two blockers, both listed above. Splitting would restart review on code that already passed. Everything else in the doc lands as follow-up PRs that reference it.

Open questions (approve / object per item)

Only points the doc does not settle, or where a reviewer proposed two shapes:

  1. D6 shape (@sugat009): you proposed either _collapsed/ or copying drops onto the promote branch. The doc picks _collapsed/. Approve, or do you want the promote-branch copy?
  2. D7 interim (@sugat009, @Hareet): ship the default-branch-first tiebreak and the disjoint-relatedFiles human flag in this PR, or defer both to dedupeByIssueId: distinguish multi-PR epics from backport cherry-picks #154? The doc allows either. My default is defer, to keep fix(#136): memory pipeline hardening + issue relinking (#135) #138 to D3+D6.
  3. D4 (@sugat009): release branches (5.1.x) in scope, in a follow-up PR. Approve or object?
  4. D5 (@sugat009): feature-branch member PRs stay out permanently; the umbrella PR represents them. Approve or object?
  5. D1 + D2 (@Hareet): the nightly opens a PR instead of pushing to main, and needs OPENROUTER_API_KEY as a repo secret. Approve the direction, and who adds the secret?
  6. D8 (@sugat009): --repo outside the schema enum fails fast. Approve or object?
  7. Context analysis agent doesn't load memory-pipeline drafts (wrong path + schema) #135 relink work (@sugat009, from your scope note): keep it in this PR since it is already reviewed. Approve or ask for a split?

D3, D9 and D10 need no answer: D3's shape is your stated preference (filter-side), D9 is agreed, and D10 is closed by main.

Verification standard (proposed, for this PR and future rounds)

Full text is committed in docs/memory-pipeline-decisions.md so it can be reviewed line by line. Summary: every behavioural claim in review gets a test at runPipeline or openReviewPR that asserts the exit signal and the audit row. Unit tests prove a mechanism exists; boundary tests prove it reaches the right channel.

Verification standard in full (click to expand)

The problem this solves

Review rounds on #138 follow the same pattern: a mechanism is fixed and with a proving unit test, but upon reviewing and tracing it through the pipeline and it's found to have ended up in the wrong channel.

The D3 blocker is the clearest case. The base-branch gate works: scraper.spec.ts
proves the scraper rejects a PR merged into a feature branch. But the rejection
travels as a ScraperError. In run-pipeline.ts that path increments failures and
exits 1. The nightly dies and no audit row is written. The unit test passed; the
behaviour was still wrong.

The same shape appears in other review findings:

Finding Unit test says Pipeline says
Base-branch gate Scraper rejects the PR Whole batch fails, no audit row
scraper.spec.ts:178 Guard throws Throw is swallowed by the fail-open catch; the test cannot fail
stripBoilerplate Function strips comments Neither production call site is covered
warn audit entry Entry is written Nothing reads it; a human never sees it

The standard

A behavioural claim is "fixed" only when a test at the pipeline boundary proves it.

The pipeline boundary is runPipeline (nightly path) and openReviewPR (promotion
path). Those two functions are where a decision becomes observable: an exit code, a
written draft, a row in _skipped.ndjson, or a git action.

Each boundary test asserts two things:

  1. The exit signal. For runPipeline: state.failures and whether process.exit
    was called. For openReviewPR: the returned status per domain.
  2. The audit row. One appended entry in _skipped.ndjson with the expected
    decision and a reason that names the case.

If a change alters neither the exit signal nor the audit trail, it is not a
behavioural change and a unit test is enough.

Why this shape

  • It is already the house pattern. test/scripts/run-pipeline.spec.ts has
    "processes every PR and does not exit when all succeed" and "records a failure and
    exits 1 when a PR throws". The D3 test is a third sibling. No new harness.
  • It ends "which channel" arguments. The open blockers are all about channel:
    error versus skip, delete versus move, log versus surface. A boundary test names the
    channel in its assertion.
  • It gives the review a citation. A reviewer writes "covered by
    runPipeline › skips a non-default-base PR without failing the batch" instead of
    re-tracing the code.
  • It exposes tests that cannot fail. Delete the guard, run the test, watch it
    fail. That mutation check is how the scraper.spec.ts:178 gap was found.

Applied to the two open blockers

D3

runPipeline([excludedPR, normalPR])
  → failures === 0
  → process.exit not called
  → _skipped.ndjson gained one row: { prNumber: excludedPR, decision: 'skip', reason: /non-default branch/ }
  → normalPR still produced a draft

D6

openReviewPR(domain with two same-issue drafts)
  → canonical promoted
  → dropped draft exists at _pending/_collapsed/<domain>/<file>
  → dropped draft no longer in _pending/<domain>/
  → audit row: { decision: 'skip', reason: /duplicate of .* "<dropped title>"/ }
  → check-pending over the tree passes (ignores _collapsed/)

Cost

One extra it block per behavioural change, about 15 to 30 lines. The runPipeline
tests already stub processSinglePR; the D3 test needs a stub that returns a skip
decision and writes the row. No new fixtures, no new frameworks.

What it does not cover

  • Distillation quality. Whether a draft is good is a judgement, not an assertion.
  • Live gh behaviour. Boundary tests stub gh. Live checks (like the 10767/10432
    validation) stay a manual step, recorded in the PR comment.
  • Workflow YAML. run-pipeline.yml steps are not reachable from mocha. D1 needs a
    real run to prove itself.

In one sentence

Every behavioural claim in review gets a test at runPipeline or openReviewPR that
asserts the exit signal and the audit row. Unit tests prove a mechanism exists;
boundary tests prove it reaches the right channel.

…failure (d3)

The scraper carries baseRefName and defaultBranch on ScrapedPR; checkSkipRules
decides. The exclusion writes a _skipped.ndjson row and leaves the exit code
untouched. Boundary test at runPipeline: failures === 0 plus one skip row.
…ad of deleting (d6)

The audit row carries the dropped draft's title and its new path. Discovery and
check-pending never read _collapsed/, proven by test.
@alexosugo

alexosugo commented Aug 27, 2026 •

Copy link
Copy Markdown
Contributor Author

Here are D3 and D6 with their boundary tests, per the verification standard above.

D3 — 95e9c84. The scraper no longer throws on a non-default base. ScrapedPR now carries baseRefName and defaultBranch; checkSkipRules returns Merged into non-default branch '<base>' (default is '<default>'). filterPR writes the audit row and returns skip, so state.failures is untouched, exit code stays 0, --resume sees the PR as handled, and reconcile counts it under otherFlags. Fail-open is unchanged: no baseRefName → no lookup; lookup failure → defaultBranch undefined → no skip.

Boundary test: run-pipeline.spec.ts › skips a PR merged into a non-default branch without failing the batch (D3). It runs the real filterPR against a temp audit log with a two-PR batch and asserts: process.exit not called, only the mainline PR distilled, exactly one row { prNumber: 20, decision: 'skip', reason: /non-default branch/ }. Mutation check done: with the rule removed the test fails on expected [ 20, 21 ] to deeply equal [ 21 ].

One deviation from the D3 text: PR #N is not merged stays a ScraperError. An unmerged PR has no mergeSha/mergedAt, so it cannot become a ScrapedPR for the filter to skip. It is reachable only through an explicit --pr, where an error to the operator is the right signal; the nightly lists merged PRs only.

D6 — d74f29a. finalizeDedupDrops renames the dropped draft to _pending/_collapsed/<domain>/<file> instead of unlinkSync. The audit row now reads duplicate of <id> — collapsed into <ref>; "<dropped title>" moved to _collapsed/<domain>/<file>. discoverDraftsByDomain iterates CHT_DOMAINS and check-pending reads one level deep, so neither ever sees _collapsed/; both now have a test that says so.

Boundary test: open-review-pr.spec.ts › apply promotes the lowest-PR-numbered canonical with source_prs and moves the duplicate to _collapsed (D6) asserts the file is byte-identical at its new path, discoverDraftsByDomain returns nothing, and the audit row carries the dropped title and path.

Gate chain, local: npm run lint, tsc, npm run check-pending, npm test (1104 passing). Diff for both: 10 files, +120/−31.

@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.

I reviewed the new work at c0d01d0. Both round-2 blockers are fixed, with tests at the
pipeline boundary. For D3: ScrapedPR now carries baseRefName and defaultBranch. The filter
decides, and the scraper no longer throws. The spec covers the skip and the fail-open case. For
D6: finalizeDedupDrops moves drops to _pending/_collapsed/<domain>/. The audit row keeps the
title. check-pending ignores _collapsed/. Writing the decisions down was the right move.

Gates at head: tsc clean, 1104 tests pass, validate-schema 167/0/3. Merge onto main is
clean.

What blocks: the decision doc ships in this PR and disagrees with the code it ships with, in
three places. D3 says the unmerged-PR error moves to the skip channel. The code keeps the throw,
with a good reason. The D6 test sketch says decision: 'skip'. The code and the shipped test say
flag-for-human. D7 says the dedup fixture "is renamed". It is not. Each has a one-click fix
inline, on the doc or on the test. Two more items are inline. The retargeted scraper spec still
cannot fail, which breaks the verification standard this same doc commits to. And the D6 catch
swallows every move failure, not only the already-gone case.

Also inline: the D4 example is wrong. PR 11137 (5.1.x, issue #11128) has a master twin. That
twin, #11133, merged the same day. So the pair shows a backport, not lost knowledge. And the
framing paragraph says 50 scheduled runs. The count was 64 on the doc's date and is 68 today.

The audit trail needs a durable home. D3 and D6 rest on audit rows. But _skipped.ndjson is
untracked since #158, and CI has never persisted it. A row that dies with the CI runner is not an
audit trail. Proposal, for the decisions doc as a new decision: un-ignore the file. First close
the #146 remainder, so a test run can never write to it again. That remainder is logPath in
the four option-less filterPR spec calls, plus a NODE_ENV=test guard. Real rows then ride the D1 nightly PR next
to the drafts they explain. A manual run also writes the file, and that is intended. A human-run
exclusion is still an exclusion. This reverses part of #158. The reversal is safe once the
test-pollution half is fixed. Implementation goes to a follow-up PR per D11, not into #138.

As a decider, here are my reactions to the decisions, one line each:

  • D1 approved. The nightly opens a PR. This also gives the audit rows a home (see above).
  • D2 approved. Hareet owns the admin step of adding the secret.
  • D3 approved, with the amendment in comment 1. The unmerged-PR error stays a throw. One
    behavior change to know: the base-branch gate now lives in the filter, so --force bypasses
    it. The old scraper throw did not allow that. It is intended. A person who passes --force
    knows what they do. Comment 1's text records it.
  • D4 approved. The rule stays simple: default branch plus the N.N.x regex. Comment 4 fixes
    the example.
  • D5 approved. One big PR into master carries the knowledge better than its member PRs.
  • D6 approved as shipped. The audit channel is flag-for-human (comment 2). When there is
    doubt, ask the human.
  • D7 approved as deferred to #154, all three parts. D6 removed the destructive edge, so
    waiting for real corpus data is the simple option. Take the fixture rename now (comment 7).
  • D8 approved.
  • D9 approved. #138 first. Note #127 now carries my REQUEST_CHANGES, so it waits anyway.
  • D10 approved. Ruled earlier, and #158 closed it on main.
  • D11 approved, with two amendments. Comment 5 adds three items to the follow-up list. The
    audit-trail proposal above becomes a new numbered decision in this file.

Comment thread docs/memory-pipeline-decisions.md Outdated
Comment thread docs/memory-pipeline-decisions.md Outdated
Comment thread docs/memory-pipeline-decisions.md Outdated
Comment thread docs/memory-pipeline-decisions.md Outdated
Comment thread docs/memory-pipeline-decisions.md Outdated
Comment on lines +184 to +191
it('does not look up the default branch when metadata carries no baseRefName', () => {
const { scrapePR } = loadWithBase(metaWithBase(), () => {
throw new Error('repo view must not be called');
});
const pr = scrapePR(7);
expect(pr.baseRefName).to.be.undefined;
expect(pr.defaultBranch).to.be.undefined;
});

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.

issue (non-blocking): This test cannot fail for the property in its title. The
defaultBranch fail-open catch swallows the stub's throw. This is the same hole my round-2
comment named on the old test at :178. Your own verification standard says: delete the guard,
run the test, watch it fail. This test fails that standard on the day it ships. Record calls
instead of throwing:

Suggested change
it('does not look up the default branch when metadata carries no baseRefName', () => {
const { scrapePR } = loadWithBase(metaWithBase(), () => {
throw new Error('repo view must not be called');
});
const pr = scrapePR(7);
expect(pr.baseRefName).to.be.undefined;
expect(pr.defaultBranch).to.be.undefined;
});
it('does not look up the default branch when metadata carries no baseRefName', () => {
const repoViewCalls: number[] = [];
const { scrapePR } = loadWithBase(metaWithBase(), () => {
repoViewCalls.push(1);
return DEFAULT_MASTER();
});
const pr = scrapePR(7);
expect(repoViewCalls).to.have.length(0);
expect(pr.baseRefName).to.be.undefined;
expect(pr.defaultBranch).to.be.undefined;
});

Comment thread test/scripts/dedup.spec.ts Outdated
Comment thread src/scripts/open-review-pr.ts Outdated
Comment thread docs/memory-pipeline-decisions.md Outdated
alexosugo and others added 4 commits September 9, 2026 17:45
Co-authored-by: Sugat Bajracharya <30311933+sugat009@users.noreply.github.com>
Co-authored-by: Sugat Bajracharya <30311933+sugat009@users.noreply.github.com>
- decisions doc: D6 sketch uses flag-for-human, D4 rationale drops the 11137 example,
  D11 lists the three missing follow-ups, status line points at recorded reactions,
  D7 fixture note matches the spec, new D12 for a tracked audit trail
- open-review-pr: narrow the _collapsed move catch to ENOENT and warn otherwise, with test
- scraper.spec: record repo-view calls so the no-baseRefName test can fail
- dedup.spec: rename the 10792 fixture to backport cluster

@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.

I reviewed the delta c0d01d0..0d2b28a at 0d2b28a. All nine round-3 suggestions are in. Eight
are byte-identical. The catch fix moved into moveToCollapsed with the same behavior, and your
new test proves it: it fails when I revert the catch to swallow everything. The retargeted
scraper test now fails when the lookup runs without a baseRefName. I checked both by mutation.

D12 matches the audit-trail decision from my round-3 review. Approved. Its precondition is right:
four option-less filterPR calls remain in filter.spec.ts (:208, :221, :234, :263).

Gates on a fresh npm ci at head: tsc clean, 1105 tests pass, nyc thresholds pass, lint clean,
validate-schema 167/0/3. Merge onto current main is clean. CI is green, SonarCloud included.

Approving. Two nitpicks inline, both one click. After this merges, #127 rebases once per D9.

Comment thread docs/memory-pipeline-decisions.md Outdated
Comment thread test/scripts/open-review-pr.spec.ts
@sugat009

Copy link
Copy Markdown
Member

@Hareet, your CHANGES_REQUESTED from 16 July is the last block on this PR. I approved at 0d2b28a today. Can you re-review, or approve if your July points are resolved?

alexosugo and others added 2 commits September 23, 2026 11:36
Co-authored-by: Sugat Bajracharya <30311933+sugat009@users.noreply.github.com>
Co-authored-by: Sugat Bajracharya <30311933+sugat009@users.noreply.github.com>

@Hareet Hareet 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.

Re-reviewed at 8235a08: all of my July points are addressed. AGENTS.md and the beads .gitignore lines are gone. slugIssueNumber now shares relink's FILENAME_TOKEN_RE (descriptive numbers like fix-500-… no longer trip the CI guard). Drafts with a missing id stay separate. domainFit is the first tiebreaker. open-review-pr prints its own reconciliation. reconcile tolerates a missing reason. source_prs is inserted as a single key. Each fix has a test. Locally on Node 22: build clean, 1106 passing, lint and check-pending clean.

Two non-blocking notes for later: run-pipeline's CI-guard/dedup counts are always 0 at that call site, and the tighter token regex drops relink's filename cross-check on 8740-7599-forms-module-uplifted-enketo.md (its metadata is already correct).

Approving! Thanks for getting all this in

@sugat009
sugat009 merged commit c41ae01 into main Sep 29, 2026
4 checks passed
@sugat009
sugat009 deleted the feat/136-memory-pipeline-feedback branch September 29, 2026 09:53
Hareet added a commit that referenced this pull request Oct 6, 2026
…ipeline for review (#131)

* chore(memory): promote strong-fit authentication drafts for review

* fix(#135): relink id/issueNumber/issueUrl to real issues (metadata-only, authentication)

Deterministic relink via #129's relink-issues tool: drafts whose identity
keys recorded the merge PR now point at the resolved issue. Frontmatter
id/issueNumber/issueUrl lines only; bodies untouched.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(#135): collapse duplicates + cross-domain review fixes (authentication)

Per sugat009's review on #131: collapse the #8868 backport pair
(8924+8933) into one memory with source_prs[]; drop 8843 (feat(na),
closes no tracked issue — #136 skip-and-flag policy).

Cross-domain dedup: fold the webapp display facet (PR #9094, moved from
the contacts seeder) into the #6543 canonical here (source_prs 9094 +
9126); drop 9204/9205/10222 whose issues (#9203/#9065/#9835) are
canonically owned by the contacts corpus — their PR refs get recorded
there in a follow-up commit on #132.

Suspect 9955 verified against cht-core: PR body explicitly closes #9735
(the SSO epic), so the stored key was already correct; the filename
token 9760 is a stale title scope (issue #9760 is owned by 9800's file).

Also: backfill related_issues across the SSO issue family (epic 9735 +
sub-issues), scrub reviewer/process narrative from 19 files, add the
optional source_prs schema definition (identical to #138 and #132).
All 39 PR-to-issue mappings verified against the live cht-core API
(0 mismatches); validate-schema 98/98; no duplicate issueNumbers.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(#135): record tasks seeder PR on the #6543 canonical (authentication)

Companion to the tasks (#123) seeder's cross-domain dedup: this corpus
canonically owns issue #6543 (multi-facility users), so the aggregate-
targets facet from that seeder is recorded here — PR #9099 added to
source_prs with a one-line account (aggregate targets gated off for
multi-facility users). The canonical now carries all three facets:
webapp display (#9094), v3 users API + authorization (#9126), and
aggregate-targets gating (#9099).

validate-schema 98/98; no duplicate issueNumbers.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(#136): round-2 review + full grounding pass (authentication)

Review 4745314944 on 112b4c7: five inline accuracy items and two nitpicks.
All five held when re-derived at each draft's own anchor, and are applied:

  8738  con_create_people -> can_create_people everywhere (0 hits at
        91c934920; the PR uses can_create_people/can_create_places).
  9833  getOidc -> oidcAuthorize (GET login/oidc/authorize) + oidcLogin
        (GET login/oidc); routing.js:305-306 at bd9b23243. getOidc came
        from the #9765 issue body and never shipped.
  9901  the per-user field is the `oidc` flag; `oidc_provider` is the
        app_settings provider config (token-login.js:286 at f74d663dc).
  9900  a text input (#sso-login) bound to editUserModel.oidc_username,
        not a checkbox setting oidc_provider (edit_user.html:80).
  10795 hasPermissions/hasAnyPermission read ctx.settings.getAll()
        (auth.js:96/:132); chtRolesSettings is only filterRolesByConfigured's.

Nitpicks: Domain Rationale rubric leakage was in eight drafts, not four
(9731 10599 10994 9128 + 8738 9107 9109 10795); all rewritten to argue from
the code. 10502 related_issues gains #6784, symmetric with 10414.

Then every draft went through the gate suite the landed domains converged
on, plus a per-draft audit against cht-core (anchor, master, and the
commits between). What the review did not name, worst first:

**Mechanism inverted or invented (no gate sees these).** 8776 said the
PR added admin detection; it removed the #7410 admin-password path and
throws "Admin passwords must be changed manually in the database" — and the
issue's UI error ("Password is not correct.") was described as an apparent
success. 8738's Problem had the polarity backwards (#8730: users with only
can_create_people could NOT create people). 9723 "clears browser history"
is a pushState that adds an entry. 9131 named the wrong redirect vector.
10414 said the Safari message was informational; the PR hides the login
fields. 9107 named fields that are not protected and called a deleted
service "companion changes". 10795's user-management/api hasPermission()
came from the PR description; the PR deleted both, and its "backwards
compatible" claim is false (no roles configured -> non-admins get nothing).
10004 has no settings validation, only two runtime throws, one after the
user docs are saved. 9887's guard runs after CouchDB validates credentials,
not before. 9961's email check is the api's getIdToken, not user-management.

**Fabricated names.** 10827: getRtlLocales, data-rtl-locales (x3) and
rtlLocales exist nowhere in the tree; rewritten from the diff (rtl on each
locale entry, data-rtl="true", <html dir="{{ defaultDir }}">, setDirection()).
9877: buildAuthorizationUrl — the PR builds no URL and openid-client was not
yet a dependency at d60a08fdd.

**Provenance.** Eight drafts are epic children and said nothing about it:
9800 9833 9877 9887 9900 9901 9961 squash-merged into 9735_sso (-> master
as #9955, 2cbe9c109) and 10994 into 10224-ui-extensions (-> #11050,
180c29ecf). Each now carries the "Epic child." banner with the refs/pull
fetch that makes its source_sha resolvable; five also carry
"Renamed/Superseded before landing" banners for what #9961 changed on the
branch. 9955's filename token (9760) contradicted its verified key (#9735,
PR body "Closes #9735"): renamed to 9955-feat9735-….

**Wrong file / wrong status / drift.** Twenty ground-claims findings at
the baseline (hasPermission, users_by_field, password_change_required,
can_skip_password_change, loginByToken attributed to the wrong files;
package.json, facility.js, users.spec.js added-vs-modified) plus five
stale-as-written paths (authorization.js moved by #10823, purge.wdio-spec.js
by #11139, add-user.wdio-spec.js renamed by #10153, a migration test removed
by #10187). "Added/updated" hedges replaced by the real statuses. 8857: every
pouchdb-session-authentication registration was removed on master by the
PouchDB 9 upgrade (#9988). stale: true on 17.

**Linkage and classification.** related_issues 9 -> 22 drafts (+39),
symmetric within the domain; PRs cited as issues relabelled; every gloss
quotes the real title. Category changed only where the issue's Type label
says so (9901 9961 8735 10414 9422); domainFit -> weak on 9671 10827 9422
(authentication is the least-bad home), Fit lines agree. 8857 source_prs +=
PR #9030 (chore(#8338)) and it gains #9102, the PR's own tracking issue.

Our own repairs produced 39 delta-gate findings (20 ungrounded, 11
unverifiable, 8 stale) before they converged — mostly paraphrased literals,
CSS #id forms where templates spell id="…", and create verbs governing the
wrong path. All re-worded to the form that greps; three were tool gaps
(fixed on memory/draft-verification, with the leakage patterns). A final
sweep expanded ~110 bare or relative file names in prose (users.js,
login.spec.js, libs/facility.js, test/auth.spec.js …) to full paths: a bare
basename resolves only at the repo root, so which sentences a sampled pass
flagged depended on luck.

**Regression pass over this commit's own changes.** Every sentence it changed
(427 of 776; 349 untouched) went to independent reviewers told to prove the
old text right before accepting a correction. They found 2 regressions, both
9887: issue #9763 edits its body with strikethrough (~~get~~ post,
~~oidc_provider~~ oidc) and the rewrite had read the rendered page — restored
from the raw body. 2 losses restored (9955: why the client secret is kept out
of the replicated `settings` doc; 9800: the payload boolean is the one stored
on the `_users` doc). Four over- or mis-stated new sentences corrected (9900,
9128, 9437, 10414). Reverted to the reviewed value: category on 8857 and
10994 (no issue label supports a change) and domainFit on 10414 and 10502
(both decide whether a login can proceed in Safari).

validate-schema 98/0; verify-drafts whole corpus 0 auth findings, auth with
history 0/0, --online 0 blocking / 0 unverified; ground-claims --added-lines
609/609 grounded; on frozen bytes, ground-claims x3 (0 ungrounded,
0 unverifiable, 0 stale each) and check-coherence x3 (0 contradictions,
34/34 checked each), none degraded.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix(#136): round-3 review items (authentication)

Review 5415306093 on #131 (at 118a7bd): one blocking and fourteen
non-blocking inline items, each with a suggestion. All fifteen suggestions
are applied verbatim, plus the wording the 8924 comment gave for its tag
(node-19 -> node-20) and its line-91 gloss.

  8738  blocking: the old actionbar's contacts-list pane listed place types
        only; only the contact-detail FAB lists person types (summary,
        Problem, Root Cause).
  8924  the api ran Node 20 from 4.6.0 (PR #8824), not Node 19.
  8735  remedy is pagination or streaming, per the issue thread.
  9016  by-name reads existed; none returned the getList/mapUsers shape.
  9731  the password-reset flag is keyed on caller permissions, not
        self-versus-other.
  9833  three sources of the 401 -> ssouserinvalid mapping, not one.
  10502 why the token gate is separate from checkUnsupportedBrowser().
  10994 restores the manual curl verification from the PR description.
  8857, 9126, 9128 (x2), 9955  master-drift notes / stale: true.

Nits applied where the review gave the exact text: 8735:53 "PR #8928";
9955:133/134 full #9763/#9764 titles; 9961:126 "Closes #9938"; Related
Files gains the PR-modified file in 8928, 9887 and 10153; 9731:67 takes the
reviewer's own :81 gloss for "lacks full access", which the verbatim :81
suggestion would otherwise contradict.

Two follow-through edits beyond the suggestions: 8924's summary gets the
same Node 19/20 wording as line 91, and 8857's master note adds that
@medic/couch-request wrapped request-promise-native until PR #9746
(2c5a640c5) rebuilt it on fetch, so the note no longer credits #8833 alone.

The other nits are not addressed here. lastUpdated is 2026-10-05 on the 16
touched drafts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Apply batched suggestions from code review

Co-authored-by: Sugat Bajracharya <30311933+sugat009@users.noreply.github.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: Sugat Bajracharya <30311933+sugat009@users.noreply.github.com>
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.

4 participants