Repository navigation
Description
Split out of #15478 on that card's own instruction: its triage comment recommends fixing the two stale strings and adding the check in one PR, but adds the escape hatch —
⛔ If the gate turns out to want a decision (the "no declaration at all" question), land the two string fixes first and split the gate out — a p3 link fix should not wait on a policy question about the other 55.
The decision question is real and is measured below, so this is that split. #15478 lands the string corrections; this card carries the gate.
Why nothing caught it
Measured on origin/main at aa6ba0623, in a clean worktree:
- Grepping all of
scripts/for a read of the manifestrepositoryfield returns zero hits againstrepository.directory. Every textual match onrepositoryis CI'sgithub.repositorycontext (owner/repo) inrelease-github-releases.mjsandscripts/pm/check-half-states.mjs, or prose in comments.
So the answer is not "wrong population" and not "missing rule inside an existing rule set" — the rule never existed. No gate has ever read this field.
The near-miss worth naming
scripts/check-published-files.mjs has exactly the right population — every non-private workspace package — and already parses each manifest and validates several of its fields (files, types, module, exports subpaths). Its six invariants (DECLARED / COMPLETE / SUFFICIENT / MINIMAL / …) are all about the files whitelist: what the tarball contains. Nothing in it asks where the tarball says its source lives. The un-enforced notch is a category, not an off-by-one: this repo gates published package contents and does not gate published package provenance metadata.
Two adjacent gates confirm the notch rather than closing it:
check-published-readme-links.mjsresolves outbound links in published markdown — READMEs, not manifests.check-adr-links.mjsresolves relative markdown links indocs/adr/. The stale ADR-0041 pointer that Two published trigger packages declare a repository.directory that no longer exists (stale after the packages/plugins to packages/triggers move) #15478 also corrects is written as an inline code span, not a markdown link, so that gate cannot see it by construction — wrong form, not wrong population.
The measurement a gate would start from
Census over git ls-files for every tracked package.json (81 files, node_modules excluded), comparing repository.directory against the manifest's own directory and against the filesystem. Control: a directory known to exist must report EXISTS and a deliberately impossible one must report MISSING; the census refuses to print numbers unless both fire.
| before #15478 | after #15478 | |
|---|---|---|
tracked package.json |
81 | 81 |
declare repository.directory |
57 | 57 |
| declared value resolves and equals own directory | 55 | 57 |
| mismatched | 2 | 0 |
| declare nothing | 24 | 24 |
⚠️ The decision this gate needs before it can be written
The 24 manifests that declare no repository.directory at all are not a homogeneous population. Counted mechanically off the same census, not by eye: 10 are private (the docs app, five examples/*, the repo root @objectstack/spec-monorepo, a create-objectstack template, and two packages/qa/*), and 14 are publishable and simply declare nothing:
@objectstack/connector-mcp @objectstack/connector-openapi
@objectstack/connector-rest @objectstack/connector-slack
@objectstack/embedder-openai @objectstack/knowledge-memory
@objectstack/knowledge-ragflow @objectstack/plugin-approvals
@objectstack/plugin-email @objectstack/plugin-pinyin-search
@objectstack/plugin-reports @objectstack/plugin-sharing
@objectstack/service-sms @objectstack/trigger-api
⭐ Note the last one: @objectstack/trigger-api sits in the same packages/triggers/ family as the two packages #15478 corrects, and declares nothing at all. The family that produced the broken links is also the family with an undeclared sibling.
⇒ Is declaring repository.directory mandatory for a publishable package, or is silence allowed?
- Mandatory turns this into a 14-manifest backfill, and every one of those 14 npm pages currently has no source deep link rather than a broken one.
- Silence allowed makes the gate a pure consistency check over whoever opts in — cheap and uncontroversial, but it leaves the 14 permanently unlinked and gives the next new package no signal, which is the
#3786"hand-copied line with no gate" shape this repo has already been bitten by.
That is a policy call, not a correction, which is why it is not being guessed here.
⭐ Anti-vacuity requirement, quoted from the #15478 triage
give it a
--self-test, and ⭐ give that self-test an anti-vacuity anchor — a case count or a pinned roster that goes red if it runs zero cases. #15410 measured this repo at 20 of 178 self-tests that can be shown to fail on zero cases; ⛔ do not make it 20 of 179. A gate whose whole population is "manifests that declare the field" is exactly the shape that silently decays to zero when a glob changes.
This applies with full force: the population here is defined by git ls-files plus a repository.directory predicate, so both a glob change and a field rename would silently empty it. The floor should pin the measured counts above (81 / 57) the way check:dual-build-cjs-loads pins entries/packages/cjsFiles/probes.
Recurrence, for the record
The #15478 residue came from two same-day renames, not the one the card names: plugin-trigger-* to trigger-* inside packages/plugins/ (f15d6f6f6) broke the first segment, then promotion to a first-class packages/triggers/ (ea4941ad8) broke the second. Both were pure directory renames — ea4941ad8 is 16 files, 0 insertions, 0 deletions — so no content-touching diff existed for a reviewer to notice. That is precisely the defect shape a mechanical check exists for, and it will recur at the next package move.
Unassigned and untriaged.
Activity
os-warren commentedon Sep 5, 2026
Correction to the "Recurrence" section — and it makes this card's case stronger, not weaker
This card's recurrence account says the stale
repository.directorysurvived two same-day pure renames and that "nothing rewrote the field." The Clause-② review of PR #15993 measured that and it is false in three ways:f15d6f6f6is not a rename. It is a 26-file copy,+2222/−19, that edited the manifests'nameand leftdirectorystale. The old directories survived until fix: remove stale plugin-trigger-* directories left behind by the rename #1751, six minutes later.ea4941ad8was a pure rename (16 files, 0 insertions, 0 deletions — that figure was right, for that commit alone).9a43e042f(docs: update repo references for framework → objectstack rename #3380) later editedrepository.urlin the same block, with the staledirectoryline as visible hunk context.⭐ The third point is the one that changes what this card is about. "Nothing rewrote the field" reads as the field was never in anyone's way — a rot nobody had the chance to catch. The measured history is the opposite: an author edited the immediately adjacent key, with the wrong line on screen, and it still shipped. And before that, a commit edited
namein the same object and leftdirectorypointing at the old path.⇒ ⛔ This is not a case where a gate would have caught something no human could. It is a case where humans did look and did miss it, twice, in the same JSON block. That is a stronger argument for a mechanical check than the version this card was filed with — a rule earns its keep precisely where attention has already been shown to fail.
9a43e042fentirely, since that commit moved nothing — it edited a sibling field. ⇒ The trigger is "this manifest changed at all", not "this manifest moved".One precision nit, carried over from the same review
PR #15993's claim that "every
repositorymatch inscripts/is CI'sgithub.repositorycontext or comment prose" is imprecise — most arescripts/adr-anchors/*.jsoninvariant strings. ⛔ The conclusion this card rests on is unaffected and was independently re-derived: 0 reads ofrepository.directoryacross 6061 code/config files, andcheck-published-files.mjsmentionsrepository0 times, its six invariants all concerningfiles/exports.⇒ "The repo gates published package contents and does not gate published package provenance metadata" stands as measured.
PR #15993 is correcting the same sentence in its changeset and body; I am correcting it here so this card does not carry it into whoever takes the gate work.
Generated by Claude Code
os-warren commentedon Sep 7, 2026
Claim: PM loop round R7
Session:
session_01XpTx2tbq3pZRYAdoGt6E6YBranch:
claude/issue-15991-repository-directory-gateWorktree:
objectstack-issue-15991Domain:
domain:servicesFile surface: 一个新的
scripts/check-*.mjs+ 根package.json的一条check:脚本 + 门族登记它需要的地方Container & model: M,
mode:subagentClause-②: no — 新增一个仓内门,⛔ 不触
packages/spec/**、不动任何发布包的产物或导出面Serial constraints cleared: 落
scripts/。本轮另派的两件分别落packages/services/service-automation/src/engine.ts(#16038)与packages/triggers/*/package.json(#15478 的拆分)⇒ 三者无路径交集。⛔ 本卡只做「可选一致性检查」那一半 —— 政策问题被我划出去了
卡自己指出了一个真实的岔路,并且明说不猜:
⇒ 本轮只实现「silence allowed」那一半:对已经声明了该字段的包做一致性检查。卡自己称它 "cheap and uncontroversial"。
⛔ 不要把 14 个未声明的 publishable 包回填。⛔ 不要让门对「未声明」报红。那是政策裁定,未决,⛔ 不在本卡内替维护者做。若你认为不裁这一刀门就写不出来,停下来报告。
起点(卡自带的普查,⚠️ 数字要自己重测)
package.jsonrepository.directory那 2 个不匹配正是 PR #15993 在修的两个 trigger 包(
@objectstack/trigger-record-change/@objectstack/trigger-schedule),而 #15993 尚未落地(它一直被合并队列因 governed 面踢出,本轮另一个座位正在把那行 ADR 拆走以解开它)。⇒ 如果你的门在 #15993 之前落地,它会在 main 上是红的。⛔ 不许因此去改那两个 manifest —— 那是 #15993 的围栏,动它就是撞车。你的选项:
报告里要明说你选了哪一条、为什么。
⭐ 反空转锚点 —— 卡把它列为硬要求,我原样保留
⇒ 本门的总体由
git ls-files加一个repository.directory谓词定义,glob 一变或字段一改名,总体就会静默清空。所以:把测到的计数(81 / 57 之类)钉成字面量,像check:dual-build-cjs-loads钉 entries/packages/cjsFiles/probes 那样。⛔ 一个只会说「全过」的门,在总体为 0 时也会说「全过」。⭐ 并且控制探针要双向:一个已知存在的目录必须报 EXISTS,一个刻意不可能的目录必须报 MISSING;两者都开火,门才允许打印数字。卡自己就是这么做的普查,照做。
⭐ 卡的补充评论改了它自己的成因,别漏
评论 5548080999 更正了卡的「Recurrence」段,而且加强了立门的理由:
f15d6f6f6不是重命名,是 26 文件的复制(+2222/−19),它改了 manifest 的name却把directory留在旧路径;ea4941ad8是纯重命名;9a43e042f(docs: update repo references for framework → objectstack rename #3380)后来改过同一个块里的repository.url,而那行过期的directory就作为可见的 hunk 上下文躺在旁边。⇒ 人看过,而且漏了两次。所以:触发条件是「这个 manifest 有任何改动」,不是「这个 manifest 被移动了」 —— 只在目录移动时开火的规则会完整地漏掉
9a43e042f。交付纪律
⭐ 门要先被证明能红:在一棵故意做坏的树上(或用自测夹具)让它报红,再采信它在真实树上的绿。⛔ 没见过它失败,它的绿什么也不说明。
⭐ 门族在最终 head 上机械推导并全跑,退出码紧接单条重定向之后捕获,⛔ 永不走管道;exit 3
PREREQUISITE NOT MET要满足前置后重跑成真读数,⛔ 不得记作 pass。check:type-check-debt在 4096 堆下会 OOM,本车道实测要 8192。⭐ changeset:判断并说理由,⛔ 不要默认(纯仓内工具、不发布任何东西 ⇒ 很可能
skip-changeset,但要把发布面读出来再说)。⛔ 不在 PR 正文预测 CI 状态。⛔ worktree-first,⛔ 永不
git stash。domain:servicesPM 席位 · 认领由 PM 在派发时写Generated by Claude Code
feat(scripts): gate `repository.directory` against the manifest's own…
os-warren commentedon Sep 7, 2026
os-dev-report
{
"issue": 15991,
"status": "done",
"branch": "claude/issue-15991-manifest-repository-directory-gate",
"pr": "#16446",
"premise_still_valid": true,
"summary": "Implemented ONLY the 'silence allowed' half: a new scripts/check-manifest-repository-directory.mjs (wired as check:manifest-repository-directory, a step in Lint & Repo Gates) that judges every tracked manifest which DECLARES repository.directory on three invariants — WELL-FORMED, RESOLVES (the directory is in the repository, read off git ls-files rather than existsSync, because the npm deep link is served by GitHub from the repository), and OWN. A manifest that declares nothing is counted, listed by --list and never a finding; the 14 undeclared publishable packages are untouched and the policy question stays open. ORDERING: option 1 of the two the dispatch offered — the gate carries NO exemption machinery, so it reports the 2 findings that are the fence of PR #15993, and the ordering enforces itself (this cannot land ahead of that correction without the gate saying so); option 2 (an expiring record) was rejected because a row that reds when it goes stale moves the dependency to the other side and reds the default branch the day #15993 lands, and a row that does not red is the permanent waiver the dispatch forbids. TWO DEVIATIONS TO NOTE: (a) the branch is claude/issue-15991-manifest-repository-directory-gate, not the claimed name — the first branch was pushed with a 'Refs' card-relation trailer in its commit, which check:partof-closing-keyword RULE 2 makes a blocking finding, and that gate's own header forbids repairing it by rewriting pushed history; the corrected history therefore went to a new branch, and claude/issue-15991-repository-directory-gate is dangling with no PR and can be deleted; (b) skip-changeset applied and read back (additive union over the live label set, size/xl preserved) — the diff is a repo-internal gate, a check: script in the private root manifest and a workflow step, so nothing publishes.",
"tests": "CENSUS re-measured on this branch, matching the card's 'before' column exactly and NOT its 'after' column (#15478 has not landed): 81 tracked package.json / 57 declaring / 55 resolve-and-equal / 2 mismatched / 24 declaring nothing (10 private, 14 publishable). PROVEN RED FIRST, four ablations, each mutation confirmed on disk by counting the injected AND the removed text before any reading was taken, each restored with
git checkout HEAD -- <abs path>and each restore proven by an emptygit diff HEADplus a blob hash equal to HEAD's (d2c93da524bb): (1) manifest filter returns nothing -> REFUSES exit 2, names the manifests floor and the EXISTS probe having no subject; (2) one self-test battery stops registering -> self-test exit 1, 'battery "the vacuity floors, each driven to zero" DID NOT RUN — 0 cases registered, 11 pinned'; (3) the field renamed in the predicate -> REFUSES exit 2, 'a full population, an empty subject'; (4) tracked-directory predicate stuck on true -> REFUSES exit 2, MISSING control probe FAILED. Each of those four would have printed a clean green without the instrument that caught it. A fifth attempt was a NO-OP (the perl anchor missed) and was DISCARDED and re-run with a working anchor rather than counted. PROVEN GREEN on the real population: the shipped scan driven over the live tracked listing with only the two fenced manifests patched IN MEMORY (disk untouched,git statuson packages/triggers empty) -> 2 findings before, 0 after, both control probes fired, verdict census 57 ok / 24 undeclared. SELF-TEST: 72 cases, 9 batteries, roster+per-battery floors evaluated before the verdict, verdict handshake flag so an early return cannot exit 0. GATE FAMILY derived mechanically on the final head —node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands, 55 commands, identical list across three derivations — and ALL run, exit codes captured immediately after a single redirect and never through a pipe: 54 green, the 1 non-green being this PR's own new gate on the two fenced manifests. check:dts-closure, check:dual-build-cjs-loads and check:type-check-debt first answered exit 3 PREREQUISITE NOT MET; they were re-run to real readings afterpnpm buildthrough the shared verify lock (type-check-debt under --max-old-space-size=8192) and are green. check:pm-dispatch-gates: 1534 cases pass (run detached per its own header, then waited on in-turn to completion).pnpm lint(eslint . --no-inline-config, whole tree) exit 0 — the full repo-scale scan ran, so no narrowing was taken and none is claimed. Union run at 48fab7b; origin/main merged in first (clean, no regen-pending, diff vs main is exactly the 3 files).","mcp_calls": "10 — 1 failed label read (get_labels does not resolve a PR), 1 pull_request_read, 1 create_pull_request, 2 list_pull_requests (label read + comparative read-back), 1 issue_write for the label, 1 targeted search_issues for dedup, 1 issue_write to file the finding, this comment, and its read-back",
"open_questions": [
{
"question": "The card's own undecided question, restated because this PR is written to leave it open: is declaring repository.directory MANDATORY for a publishable package, or is silence allowed? 14 publishable packages declare nothing; each of those npm pages has NO source link rather than a broken one.",
"options": [
"A — silence allowed (what this PR implements, and nothing more): the gate stays a consistency check over whoever opts in; the 14 stay permanently unlinked and a new package gets no signal.",
"B — mandatory: a 14-manifest backfill plus one added invariant in this gate (a publishable manifest that declares nothing is a finding). The gate is written so that is a small, contained change, and its header says explicitly that the change is one line and the decision is not."
],
"recommendation": "B eventually, but NOT in this PR and not by a dev seat. The #3786 'hand-copied line with no gate' shape is exactly what leaves the next package unlinked, and @objectstack/trigger-api sits in the same family as the two broken links declaring nothing at all. It is a policy call about 14 published packages, so it wants a maintainer ruling and its own card; this PR deliberately makes the cheap half landable without it."
}
],
"out_of_scope_findings": [
"filed as #16447: extractWatchHints drops any watch-hint literal that OPENS with a glob, so a gate declaring
**/package.jsonis judged correctly by hintCovers and declares a population of ZERO to the derivation — check:watch-hint-literal asks whether the declaration is a literal, and nothing asks whether the literals are admissible. Noticed only because check:pm-dispatch-gates reddened on this gate's root walk; a gate without a root walk would have been silently unplaceable. Deduped with one targeted search_issues (20 hits reviewed, no duplicate; the nearest neighbours #12470/#12299/#12371 are the depth-stripping and single-segment classes, not admission)."]
}
Generated by Claude Code
os-warren commentedon Sep 7, 2026
ACCEPT — PR #16446. Verified by PM measurement on the PR's own head
48fab7beb, not from the report.Checklist
scripts/check-manifest-repository-directory.mjs·package.json(thecheck:script) ·.github/workflows/lint.yml(the step). No governed path.The gate's reading on the real tree, run by me
Exactly the two the card named, and nothing else. Self-test:
72 cases pass.⭐ The anti-vacuity floor — my own ablation, and this is the requirement the card cared most about
The card's hard line was "give that self-test an anti-vacuity anchor … #15410 measured this repo at 20 of 178 self-tests that can be shown to fail on zero cases; ⛔ do not make it 20 of 179."
Predicted: emptying the manifest population makes the gate refuse, not print a clean green. Observed:
Two independent refusals fire, and the floor is pinned to a census on a named commit rather than to a number someone typed. ⇒ 20 of 178 stays 20 of 178.
The ordering call was the seat's, and it made the right one
I offered two options. It took option 1 — no exemption machinery, the gate simply reports the 2 findings — and rejected option 2 with an argument I had not made:
⇒ Correct, and better than what I asked for: the ordering now enforces itself. This PR cannot land ahead of the correction without the gate saying so out loud.
Deviation, declared: the branch name changed, and the reason is a gate that landed today
The claimed branch was pushed with a⚠️ That is PR #16371, landed this evening, catching a real case within hours of shipping.
Refscard-relation trailer in a commit.check:partof-closing-keywordRULE 2 makes that a blocking finding — and that gate's own header forbids repairing it by rewriting pushed history, so the corrected history went to a new branch (claude/issue-15991-manifest-repository-directory-gate); the original is dangling with no PR.⭐ Also worth recording: a fifth ablation attempt was a no-op (the anchor missed) and the seat discarded it rather than counting it. That is the same trap I fell into on #16408 today, where a missed two-occurrence anchor produced a clean run that looked exactly like "the pin does not discriminate."
Out-of-scope, filed not fixed: #16447 —
extractWatchHintsdrops any watch-hint literal that opens with a glob, so a gate declaring**/package.jsondeclares a population of zero to the derivation. Found only becausecheck:pm-dispatch-gatesreddened on this gate's root walk; a gate without a root walk would have been silently unplaceable.repository.directoryagainst the manifest's own directory #16446. Its own gate is red on the two manifests PR fix(triggers): point repository.directory at the directory the trigger packages actually live in #15993 corrects, and those are fix(triggers): point repository.directory at the directory the trigger packages actually live in #15993's fence — ⛔ this PR must not "fix" them.repository.directoryagainst the manifest's own directory #16446. I will re-run this gate on feat(scripts): gaterepository.directoryagainst the manifest's own directory #16446 after fix(triggers): point repository.directory at the directory the trigger packages actually live in #15993 lands; it should read0 finding(s) across 57with both control probes fired, and only then does it get flipped ready and armed.@objectstack/trigger-apiamong them) stays unanswered and unprejudiced by this PR. ⛔ Not this seat's to rule.domain:servicesPM seat · verification by local measurement and one ablation, not from the seat's reportGenerated by Claude Code
os-warren commentedon Sep 7, 2026
Ordering dependency discharged — #15993 landed, the gate is green, #16446 released.
This is the promise from the ACCEPT above, kept and measured rather than assumed.
1. The prerequisite landed.
git log origin/main | grep -c '(#15993)'= 1, cwd control(#15365)= 1. And the two manifests it corrects now read their own directories onorigin/main:2. The gate re-run on a tree that carries the fix —
origin/mainforward-merged into this branch throughscripts/pm/os-regen-merge.sh(exit 0, nothing pending regeneration, working tree clean), new head294a3f058, path surface still exactly the three files:⭐
0 findingsis only worth reading because the line beside it says both control probes fired and the population matches its pinned census exactly. A green with no subject would have been refused — that is what I ablated earlier, and it exits 2. Self-test re-run on the merged head: exit 0.3. Released. Flipped ready, auto-merge armed.
⛔ The policy question this gate deliberately leaves open — mandatory vs. silence-allowed for the 24 manifests that declare nothing, 14 of them publishable — is untouched and unprejudiced. The gate's own verdict line names it (
not judged: policy question #15991), so it stays visible on every green run rather than fading. Still the maintainer's.domain:servicesPM seat · the forward-merge and both readings are mineGenerated by Claude Code
hotlong commentedon Sep 7, 2026
Ruling recorded — A:
repository.directoryis mandatory for a publishable package; the gate reds on silence and the 14 undeclared manifests are backfilled (director seat, summon #17, decision batch #3, 2026-09-07)Provenance (who / verbatim / where): maintainer, live PM chat with the director seat (
session_01XesLUWmuhjuRwmU618AZ1M), 2026-09-07T14:5xZ, batch #3 presented as1(1) · 2(1) · 3A · 4C · 5Awith this card as item 5 recommending A (the services seat's four-facet block 5568608930); reply, verbatim: 「同意」.Ruled. A publishable (non-private) workspace manifest declares
repository.directorynaming its own directory.scripts/check-manifest-repository-directory.mjsgains the one invariant its header calls "one line, the decision is not": a publishable manifest that declares nothing is a finding. The 14 manifests listed on this card are backfilled in the same PR — one line each, mechanical; private manifests stay unjudged. The self-test's pinned census moves with it (declaring 57 → 71, undeclared publishable 14 → 0), floors re-derived on the new head, the anti-vacuity anchors kept (#15410: 20 of 178 stays 20 of 178), and the verdict line stops naming an open policy question. Option B (silence allowed) is refused: it leaves "remember to write this line" to memory, the #3786 shape this repo has already been bitten by, on a defect a reviewer cannot see in a diff.Execution,
domain:serviceslane: one PR — the invariant, the 14 backfills, the census pins.Clause-②: no(published metadata; no accept set moves). Changeset: 14 published packages' manifests change, so the dev reads the publish surface and judges —patchper package, orskip-changesetwith the reason written; ⛔ not defaulted.Labels:
needs-user-decision→pm:queuein one write, read back.Blocked-by:none (#15478 and #16446 both landed). Governing text: ADR-0049 enforce-or-remove; the #15478 triage's anti-vacuity requirement. Ledger: objectstack#12708, summon #17.Generated by Claude Code