Skip to content

Layout: move every authored file into its package directory (src/sales · src/service · src/revenue · src/marketing) — one stack still registers everything, artifact bit-identical #1905

Description

@os-elon-musk

Part of #1904 (step 1 of the sequence). Spec of record: docs/architecture/module-split-plan.md, Decisions recorded (2026-09-14), items 6–9 — on branch claude/gifted-planck-behk4r until PR #1906 merges. Raw URL of the spec on that branch (the markdown link form gets mangled by the body sanitizer):

https://github.com/objectstack-ai/hotcrm/blob/claude/gifted-planck-behk4r/docs/architecture/module-split-plan.md

Read it in full before the first edit; this card is the pointer, not a second copy.

Ruling (verbatim, untranslated)

「是不是拆的太细。是否可以去掉 packages 这一层」 / 「shared 有必要吗?是否后期会导致ai的混乱」 / 「为什么需要app ,我觉得是另外一种混乱」 / 「token 门禁: 放到 sales」

What this PR does — a move, nothing else

Every authored file under src/ moves (git mv, history kept) into the directory of the package that will own it. No manifest is added, composeStacks is not introduced, no navigation changes. Root objectstack.config.ts keeps its single defineStack with the app manifest and imports the collections from the four directories, concatenating them. The compiled artifact must come out the same as today's.

Target tree — top-level directories under src/ are exactly the packages:

src/
├── sales/       # app.objectstack.hotcrm (the app): account, contact, lead, opportunity, forecast, task, event, event_attendee
├── service/     # case, knowledge_article, article_feedback
├── revenue/     # product, opportunity_line_item, quote, quote_line_item, contract
└── marketing/   # campaign, campaign_member

Inside each: the metadata-type subdirectories that package uses (objects/, views/, pages/, flows/, actions/, datasets/, reports/, dashboards/, mappings/, skills/, sharing/, data/, and for sales also apps/, profiles/, translations/, docs/, interfaces/), each with an explicit file-by-file barrel exactly as today. *.hook.ts sits beside its *.object.ts; the hook registration list for a package lives at <pkg>/objects/hooks.ts (replaces src/hooks/index.ts), and the root config concatenates the four lists. One index.ts per package directory is not created in this PR (that is the packaging card) — the root config imports the per-type barrels directly.

Assignment rule and the named exceptions

An item lives with the object it is authored against (view, page, action, dataset, report, sharing rule, seed rows, import mapping, skill, hook, flow by trigger object). Items with no single object go to sales/: the app (apps/crm.app.ts), home / app_launcher / utility_bar pages, executive and crm (overview) dashboards, the cross-domain skills, all profiles, positions.ts, the four locale packs, src/docs/*.md, demo-bootstrap.flow.ts. Exceptions that need a hand:

  • src/objects/campaign.hook.ts carries four hooks; the two attached to crm_lead and crm_opportunity move to sales/objects/ (one file each, e.g. lead.campaign-metrics.hook.ts, opportunity.campaign-metrics.hook.ts); the two on crm_campaign stay in marketing/objects/campaign.hook.ts. test/campaign-member-lifecycle.test.ts pins the four bodies character-identical — keep the pin, re-point its paths.
  • src/actions/global.actions.ts: the factory becomes sales/actions/activity-actions.ts and instantiates the four sales-hosted triples there; service/actions/ instantiates the crm_case-hosted triple by importing the factory from sales/ (the only allowed direction).
  • src/flows/billing-handoff.flow.ts carries two flows on two trigger objects: the closed_won flow goes to sales/flows/, the contract_activated flow to revenue/flows/; _billing-endpoint.ts and _guarded-iteration.ts go to sales/flows/ and are imported from there.
  • Seed files are organised by cloud, not by object (sales.seed.ts carries opportunityLineItems; service.seed.ts carries tasks and events; revenue.seed.ts carries forecasts): split them along object ownership, and keep today's replay order in the root config's data list — rows reference rows seeded earlier and the seed tests pin that.
  • Shared sources take the homes in plan item 7: _hook-api.ts, _picklists.ts, _thresholds.ts, shared-widgets.ts, data/_shared.ts, mappings/_shared.ts → sales/ (their current subdirectory names); consumers in other packages import them from sales/ — never copied. One copy of each is an acceptance criterion.

Re-pointed in the same PR

  • scripts/check-source-token-ratchet.mjs: scope becomes src/sales/ (the two layers keyed by subdirectory name — objects/flows/actions business, views/pages/dashboards/apps interaction; translations/ and data/ stay excluded); print a reading for each of the four package directories; re-derive the sales ceilings from the new reading with the script's own anchor() rule, retiring the whole-tree ceilings with a worked row. Lowering the ruled business semantics ceiling is authorised by the ruling quoted above — quote it in the PR body and in the script header. test/source-token-ratchet.test.ts learns the scope.
  • scripts/check-source-hygiene.mjs if it names directories (test/source-hygiene-scan-surface.test.ts says).
  • .github/labeler.yml: every glob must still match something (test/labeler-config.test.ts).
  • test/**: path references go through one roster helper under test/helpers/ rather than 100 hand-edited strings; no pin is deleted or weakened.
  • Every doc that names or draws src/<dir>/: AGENTS.md Project Architecture (rewrite: a directory is a package; the import rule "own directory or src/sales/, never sideways"; hooks beside objects), docs/README.md Current Repository Shape, docs/ARCHITECTURE.md, docs/STATUS.md, content/docs/getting-started/for-developers*.mdx (all three locales), .github/tasks/new-feature.md and new-package.md (.github/tasks/new-package.md still prescribes the retired packages/ scaffold — #1518's class, the site that card did not reach #1819 is the same class — do not Fixes it), .github/instructions/*.md where they cite src/{type}/. test/docs-src-tree-paths.test.ts is the guard.
  • docs/architecture/module-split-inventory.json: add a $hand_edits entry recording the move (the file forbids regeneration).

Acceptance

  1. pnpm build on the base commit and on the PR head produce the same artifact: same objects with the same FQNs, fields, hooks, flows, views, pages, navigation and seed order. Prove it with a canonical-JSON diff of dist/objectstack.json in the PR body (byte-identical if the builder is deterministic; otherwise the normalised diff is empty).
  2. pnpm verify green (validate, typecheck, lint, i18n gate, hygiene, token ratchet, build, test).
  3. find src -maxdepth 1 lists exactly sales service revenue marketing; no shared/, no packages/.
  4. A grep listed in the PR body shows every cross-directory import under src/ targets src/sales/.
  5. The gate prints four package readings; the sales ceilings and their worked rows are in the script header with the ruling quoted.
  6. Changeset: empty frontmatter (the artifact is unchanged, nothing ships to users), with the declaration paragraph the repo's other zero-release changesets carry.
  7. Governed PR (touches AGENTS.md): stays draft, body carries ## 维护者速读(草稿); the maintainer merges.

Out of scope

Manifests, composeStacks, navigation contributions, per-package index.ts (all #1904 step 2). Any behaviour change. Filing the two upstream platform gaps named in plan item 7 (filed: objectstack-ai/objectstack#18163, objectstack-ai/objectstack#18164).

Activity

  1. added
    pm:epicParent delegated to a dedicated epic PM — other PMs never dispatch into its subtree
    pm:dispatchedDispatched to a dev agent by /pm-dispatch
    on Sep 14, 2026
  2. self-assigned this
    on Sep 14, 2026
  3. os-elon-musk commented on Sep 14, 2026

    @os-elon-musk
    CollaboratorAuthor

    Claim: PM loop round 1 (epic #1904)
    Session: session_01T3YsvpK1PvYf9n1YUhYP6W
    Branch: claude/issue-1905-package-directory-layout
    Worktree: hotcrm-issue-1905
    Domain: repo:hotcrm (single-lane repo; epic #1904 subtree, no domain:*)
    File surface: src/** (moves and import re-pointing), objectstack.config.ts, scripts/check-source-token-ratchet.mjs, scripts/check-source-hygiene.mjs (only if it names directories), test/** (path re-pointing through one roster helper), .github/labeler.yml, .github/tasks/*.md, .github/instructions/*.md, AGENTS.md, docs/README.md, docs/ARCHITECTURE.md, docs/STATUS.md, docs/architecture/module-split-inventory.json, content/docs/getting-started/**, README.md, .changeset/*.md (stop on breach; explain in the report)
    Container & model: L (a whole-tree move — 18 objects and every item authored against them, plus ~105 test files that name src/ paths; correctness judged by the bit-identical artifact and the gate farm, design judgement in the seed-order split and the gate re-scope), mode:subagent (the os-dev role file and both repository checkouts live in this session's container; a cloud session's source set is unverified in this environment — recorded as the reason for not using cloud on an L card), model: default judgment tier (the os-dev floor). hotcrm carries no scripts/pm/dispatch-gates.mjs, so there is no --tier output to cite; AGENTS.md is a governed surface but not a protocol-semantics surface, so construction runs at the default tier and the governed hunk is reviewed at the contract-review tier by the seat.
    Clause-②: no
    Thread-read: none (no comments on the card at the moment this claim is written; 2026-09-14T08:51Z)
    Serial constraints cleared: #1893 (draft, claude[bot], head claude/issue-1185-notify-template-conversion, in flight since 2026-09-11) touches src/flows/** and src/translations/** — files this card moves. Disposition: not folded (different defect shape, different card family); serial at landing — the dev merges origin/main immediately before opening the PR and again if #1893 lands first (git rename detection carries its hunks across the move); a conflict goes to the merge queue, never hand-ordered. #1906 (docs only, this session) and the five dependabot PRs (package.json / lockfile only) do not intersect. No other claim exists on the #1904 subtree. origin/main tip at this reading: 590b095 (2026-09-14T08:49Z); src/ still flat by metadata type — the premise holds.


    Generated by Claude Code

  4. os-elon-musk commented on Sep 14, 2026

    @os-elon-musk
    CollaboratorAuthor

    os-dev-report

    {
      "issue": 1905,
      "status": "done",
      "branch": "claude/issue-1905-package-directory-layout",
      "pr": "https://github.com/objectstack-ai/hotcrm/pull/1910",
      "premise_still_valid": true,
      "summary": "Every authored file under src/ moved by git mv into its package directory (src/sales as the type:app package, src/service, src/revenue, src/marketing) per plan items 6-9; the named exceptions were split by hand (campaign.hook.ts three ways, global.actions.ts into a sales factory plus a service instantiation, billing-handoff.flow.ts by trigger object, the three seed family files by object ownership). dist/objectstack.json is BYTE-IDENTICAL to the build on origin/main (3118ccf) - same sha256 - which took a deliberate ordering design: the builder writes each collection in the order it is handed (measured by a reverse-and-rebuild ablation on the untouched base), so the new objectstack.composition.ts reproduces the pre-move order with byExportName() for the namespace-derived collections and explicit ordered lists for hooks/flows/skills/sharingRules/data. Two premises of the card were falsified by measurement and are recorded in the PR body: (1) objectstack.config.ts may carry NO named export - the build parses it against a strict stack schema - which is why the collection step is a sibling module rather than the top of the config, confirmed by appending a probe export to the UNMODIFIED config at 590b095 and watching the build fail by name; (2) src/docs/ cannot move under src/sales/ - objectstack build reads package docs from (config dir)/src/docs and no other path (ADR-0046), and moving them is silent: exit 0, usual 'Collecting package docs' line, artifact loses its docs[] key. src/docs/ therefore stays at the top of src/, which is a declared deviation from acceptance item 3, taken because acceptance item 1 (bit-identical artifact) is the card's headline and compensating in this repo would be platform tooling AGENTS.md forbids.",
      "gates": {
        "pnpm validate": "exit 0",
        "pnpm typecheck": "exit 0",
        "pnpm lint": "exit 0",
        "pnpm lint:i18n-gate": "exit 0",
        "pnpm hygiene": "exit 0",
        "pnpm hygiene:tokens": "exit 0 - business semantics ~49,978 (ceiling 53,000) / interaction layer ~29,344 (ceiling 31,000) / authored total ~91,910 (ceiling 97,000), plus the four per-package readings",
        "pnpm build": "exit 0",
        "pnpm test": "exit 0 - 165 files, 3474 passed, 1 skipped",
        "artifact diff": "cmp exit 0 against origin/main 3118ccf; sha256 7165de9aba7aa4ef27e8f05bf77236832eb693fe83e949e52da32c87ae594506 both sides",
        "note": "every step run separately with its exit code written to a file before any output was read; all eight re-run after merging origin/main (3118ccf) and all still 0"
      },
      "line_budget": "n/a - no skills/** file touched, so no line ratchet applies to this PR",
      "tests": "pnpm test 165 files / 3474 passed / 1 skipped. New: test/helpers/src-roster.ts (the one place a suite learns the package layout; app-wide sweeps take their file rosters from it). Re-scoped: test/source-token-ratchet.test.ts now derives LAYER_DIRS/EXCLUDED_DIRS from the gate's own exported constants instead of a second hand-written copy, and exercises the RULED-ceiling branch against a sandbox gate patched to declare one (no committed ceiling is ruled after the re-anchor, and the branch is still the gate's). Ablations, both on the UNMODIFIED base worktree with a trap-based restore and a git-diff-empty check afterwards: (a) determinism - two builds of the base are byte-identical; (b) order sensitivity - appending .reverse() to objects/hooks/views in the base config changes the artifact, which is what makes the ordering design load-bearing rather than decorative; (c) the named-export probe described in summary. No permanent test file was left behind by any of them.",
      "mcp_calls": "4 - issue_read get, issue_read get_comments, create_pull_request, pull_request_read get (the body read-back). Plus this comment.",
      "api_writes": "3 git pushes to the branch (the empty-branch write probe, then two content pushes). No REST proxy writes and no label write - this repo's labeler applies what it applies and the card required none.",
      "deviations": [
        "src/docs/ stays at the top of src/ rather than moving to src/sales/docs/, so `find src -maxdepth 1` lists five entries, not four. Reason measured, not assumed: the CLI resolves package docs as path.join(dirname(configPath), 'src', 'docs') and reads nothing else, so under a single-config build no src/<pkg>/docs/ is ever read. Moving them keeps the build at exit 0 and silently drops the artifact's whole docs[] array. Acceptance 1 (bit-identical artifact) and AGENTS.md rules 1-3 (a platform gap is filed upstream, never compensated for here) both point the same way. Written into AGENTS.md beside the tree and into the PR body as a declared deviation.",
        "The collection step lives in a NEW root module, objectstack.composition.ts, rather than at the top of objectstack.config.ts as the card's wording implies. Forced: the config may carry no named export (strict stack schema), and about a dozen suites assert against the AUTHORED collections upstream of defineStack() - the only side of that call where CrmSeedData and the stack's data are distinguishable, as test/saas-composition.test.ts states in its own comment. The file is enrolled in ROOT_TEXT_FILES (scripts/lib/source-hygiene-surface.mjs) and in the labeler's `configuration` globs.",
        "Per-package hook barrels (<pkg>/objects/hooks.ts) are named re-export barrels, like every other barrel in this tree, and the assembled ordered list lives in objectstack.composition.ts rather than being four package lists concatenated at the root. Concatenation cannot reproduce the pre-move flat hook order, which interleaves the packages (account=sales, article_feedback=service, campaign=marketing, campaign_member=marketing, case=service, contact=sales, ...), and the builder writes that order into the artifact. Same reasoning for flows, skills, sharingRules and the seed replay order.",
        "The token gate's `business semantics` ceiling was a RULED 100,000 (#1601). All three ceilings are re-anchored from the sales reading, which for that one is a LOWERING and therefore needs a ruling quoted in the lowering PR - the ruling is the one that put the gate on sales, quoted verbatim and untranslated in both the script header and the PR body.",
        "The README banner's two headline figures change meaning, not just value: ~85k/~37k (whole tree) becomes ~50k/~29k (the sales package), because the gate now measures the unit ADR-0130 makes the claim about. Flagged in the maintainer section for confirmation. The repository's GitHub DESCRIPTION still reads 'business logic under 100k tokens' - a repo setting, not a file, so outside this PR's diff; listed under out_of_scope_findings."
      ],
      "files_changed": "374 files, +6971 / -5711 across 4 commits. 205 files moved under src/ (git mv, history preserved); new: objectstack.composition.ts, test/helpers/src-roster.ts, src/sales/objects/{lead,opportunity}.campaign-metrics.hook.ts, src/service/actions/case-activity.actions.ts, src/revenue/flows/billing-handoff-contract-activated.flow.ts, src/revenue/data/opportunity-line-item.seed.ts, src/sales/data/{activity,forecast}.seed.ts, four <pkg>/objects/hooks.ts, the per-package per-type barrels, .changeset/a-directory-under-src-is-a-package.md; retired: src/hooks/index.ts and the fifteen flat src/<type>/index.ts barrels.",
      "open_questions": [],
      "out_of_scope_findings": [
        "to file (3 classes, dedupe words: package docs, src/docs, ADR-0046, collect-docs, multi-package): PLATFORM - the ADR-0046 package-docs collector resolves exactly path.join(dirname(configPath), 'src', 'docs'), so under the ADR-0130 multi-package layout no per-package docs directory is reachable, and moving the directory produces a GREEN build (exit 0, the usual 'Collecting package docs' step printed) whose artifact has lost its docs[] entirely. Silent metadata loss on an exit-0 build is the hazard class, not the fixed path itself. Belongs upstream in objectstack alongside the two gaps plan item 7 already filed (#18163, #18164); this repo must not compensate for it.",
        "to file (3 classes, dedupe words: objectstack.config, named export, strict stack schema, unrecognized_keys, config exports): PLATFORM - objectstack.config.ts may carry no export beside the default, because the build parses the whole config MODULE against the strict ObjectStackDefinitionSchema. The diagnostic is good ('Unrecognized key(s) on this stack definition'), but it names a config-authoring constraint nothing documents, and it is what forces any app wanting importable composition helpers into a sibling module. Worth an upstream decision: either document the constraint on the config surface, or read only the default export. Measured on hotcrm at 590b095, unmodified.",
        "noted, not filed: the repository's GitHub description still reads 'business logic under 100k tokens', which was the whole-tree claim; the gate now commits 53,000 for the sales package and the README banner says ~50k. It is a repo setting rather than a file, so no PR can carry it. Successor: the maintainer, when confirming the new ceilings in PR #1910 (the maintainer section asks about exactly these figures).",
        "noted, not filed: src/sales/interfaces/index.ts is an empty barrel - a copyright header and nothing else - registered by nothing and imported by nothing. It moved with the tree unchanged because this PR is a move. Successor: the packaging PR (#1904 step 2), which decides each package's index.ts surface and will have to decide whether this one has a reason to exist.",
        "noted, not filed: docs/architecture/module-split-inventory.json files[] is short by 35 files that postdate its measured_at commit (three flows and the 32 per-locale translation modules). Left out deliberately per the file's own $maintenance rules - it is evidence for one measurement, not an index of the tree - and recorded in the $hand_edits entry this PR adds. Successor: whoever next re-measures the split; there is no live pointer broken by it."
      ]
    }

    Generated by Claude Code

  5. os-elon-musk commented on Sep 14, 2026

    @os-elon-musk
    CollaboratorAuthor

    Verdict: ACCEPT — PR #1910 (draft, governed: touches AGENTS.md; awaiting the maintainer's own merge). Reviewed by the epic PM, session session_01T3YsvpK1PvYf9n1YUhYP6W, at 2026-09-14T10:14Z against PR head e06226c (base 3118ccf).

    Implemented-by: os-dev subagent, this session (branch claude/issue-1905-package-directory-layout)
    Reviewed-by: epic PM, this session

    Checklist conclusions (verified on GitHub and on the fetched branch, not on the report's narrative)

    • PR shape: draft ✓, base main ✓, first line Fixes #1905 ✓; the epic is Part of #1904 and .github/tasks/new-package.md still prescribes the retired packages/ scaffold — #1518's class, the site that card did not reach #1819 / docs(architecture): PSA module design, REQ-0002, and the final ADR-0130 module shape #1906 appear with no closing keyword adjacent ✓.
    • Scope: 374 files — 197 renames, 36 adds, 4 deletes under src/ (the flat data/, flows/, hooks/, skills/ index barrels), plus the declared re-pointing surface. Two files outside the claim's declared surface, both mechanical import re-pointing forced by the move: scripts/demo-staff.ts, scripts/analytics-reconcile/run.ts (read: they merge the per-package barrels). docs/DEPLOYMENT.md, docs/MAINTENANCE.md, docs/developers/*, content/docs/customization/* are the same class (docs naming src/<dir>/). No content/docs/releases/ change ✓. Changeset: empty frontmatter with the zero-release declaration ✓ — correct for this repo since the artifact is unchanged.
    • Gate evidence, pinned to head e06226c: all 10 check runs completed: success — Quality Checks, Typecheck and Build, Build and Test (22.x), Check Changeset, Playwright, link-check, CodeQL, Analyze Code, Label Pull Request, Vercel ✓.
    • Artifact byte-identity, re-measured independently: two fresh worktrees (3118ccf and e06226c), pnpm install --frozen-lockfile + pnpm build in each, cmp exit 0, sha256 7165de9aba7aa4ef27e8f05bf77236832eb693fe83e949e52da32c87ae594506 on both sides — the dev's headline reading reproduces ✓.
    • Import direction, re-measured on the branch tree: 32 cross-directory specifiers, all ../../sales/ ✓. src/ top level: docs, marketing, revenue, sales, service ✓ (see deviation 1).
    • Governed hunk (AGENTS.md Project Architecture), read at the contract-review tier: states the directory-is-a-package rule, the three layout rules (hook beside its object, imports along the edges only, item lives with its object), keeps and explains the packages/<x>/src/ prohibition as the retired monorepo shape, and records the src/docs/ exception with its reason. Consistent with plan items 6–8; no protocol-semantics text changed ✓.
    • Gate script: scope src/sales/, per-package readings printed, all three ceilings re-anchored by anchor() with worked rows; the lowering of the ruled business semantics ceiling quotes the ruling 「所以应该先分拆基础的crm, 比如 sales 和 support, token 门禁: 放到 sales」 in the header ✓.

    Deviations, all accepted

    1. src/docs/ stays at the top of src/ — the CLI reads package docs from <config dir>/src/docs only (confirmed in the pinned @objectstack/cli dist: "compile flat src/docs/*.md"); moving it is a silent loss. The plan is amended beside item 6 (claude/gifted-planck-behk4r, PR docs(architecture): PSA module design, REQ-0002, and the final ADR-0130 module shape #1906) and the gap is filed as cli: the ADR-0046 package-docs collector reads only <config dir>/src/docs — under an ADR-0130 package layout a moved docs directory produces a green build whose artifact has silently lost docs[] objectstack#18170.
    2. objectstack.composition.ts as a sibling module — forced by the config module's strict parse (measured on the unmodified base); filed as cli: objectstack.config.ts may carry no named export — the build parses the whole config module against the strict stack schema, and nothing documents that constraint objectstack#18171.
    3. Hook / flow / skill / sharing / seed ordering assembled in the composition module rather than concatenated per package — required for byte-identity; the packaging card decides the per-package index.ts surface.
    4. README banner figures now describe the sales package (~50k / ~29k) instead of the whole tree — for the maintainer to confirm in the quick-read.

    Findings from the report: the two "to file" platform findings are now filed (#18170, #18171); the three "noted, not filed" items stay noted (repo description text; the empty src/sales/interfaces/index.ts barrel — for the packaging card; the inventory's stale files[] — recorded in its $hand_edits).

    Next: needs-user-decision on this card = the maintainer's merge decision on #1910 (governed PR, no seat flips it ready). #1907 stays blocked until #1910 is MERGED.


    Generated by Claude Code

  6. added
    needs-user-decisionNeeds the maintainer's call before work proceeds
    and removed
    pm:dispatchedDispatched to a dev agent by /pm-dispatch
    on Sep 14, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

enhancementNew feature or requestpm:epicParent delegated to a dedicated epic PM — other PMs never dispatch into its subtree

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions