Skip to content

feat(build): require plan-id, relocate results, extend ledger - #1075

Merged
cuioss-oliver merged 13 commits into
mainfrom
feature/mandatory-plan-id-build-results-ledger
Aug 2, 2026
Merged

feat(build): require plan-id, relocate results, extend ledger#1075
cuioss-oliver merged 13 commits into
mainfrom
feature/mandatory-plan-id-build-results-ledger

Conversation

@cuioss-oliver

@cuioss-oliver cuioss-oliver commented Aug 1, 2026

Copy link
Copy Markdown
Collaborator

Summary

Three chained changes to the build-class dispatch boundary:

  1. plan_id becomes mandatory across all 27 build-class subcommand registrations (9 distinct subcommands across the four wrappers), using the existing truthy NO_PLAN sentinel. A build invoked outside any plan now states that fact instead of leaving the field absent — the kind=build ledger row's plan_id goes from str | null to never-null.
  2. Build results relocate from the shared .plan/temp/build-output/ tree into {plan_dir}/build-results/{scope}/, making every build log a durable plan artifact that moves with its plan. get_build_results_dir in marketplace/bundles/plan-marshall/skills/tools-file-ops/scripts/file_ops.py is the single owner of that path; the NO_PLAN sentinel is the one main-anchored branch, because a plan-less build belongs to no worktree.
  3. The kind=build ledger row gains three fields — the actual resolved command the wrapper ran, duration_seconds, and outcome (the wrapper's whole stdout TOON). args is unchanged and stays a distinct layer: args is the executor argv, command is what the wrapper resolved that argv into.

Plus the two things the relocation made necessary: a retention lifecycle (build_results_days, cleanup --target build-results) with a live-plan guard, and a generic *.template classification rule at the manage-execution-manifest aggregator.

duration_seconds was already produced by every build result and silently discarded at the ledger boundary — item 3 is plumbing, not new instrumentation.

Changes

Mandatory plan id (NO_PLAN sentinel)

  • script-shared/scripts/build/_build_cli.py declares the --plan-id / --project-dir routing pair on every build subcommand, so the surface is uniform rather than per-wrapper.
  • script-shared/scripts/marketplace_paths.py gains names_real_plan(), a TypeGuard[str] next to the sentinel it interprets. The sentinel is truthy, so if plan_id: is a vacuous guard against it; every affected site was de-vacuumed.
  • tools-script-executor/templates/execute-script.py.template keeps two deliberately distinct values: _active_plan_id (caller-supplied, selects a plan directory for the script log) and _ledger_plan_id (falls back to the sentinel, stamps the row). Collapsing them would redirect a plan-less build's script log out of the global path.

Relocation + retention

  • tools-file-ops/scripts/file_ops.py / constants.py: get_build_results_dir, plus CLEANUP_TARGET_BUILD_RESULTS as a member of the closed CLEANUP_TARGETS tuple so the dispatcher and the argparse choices come from one edit.
  • manage-run-config/scripts/_cmd_cleanup.py: the cleaner and its cleanup-status counterpart. The plan set is enumerated from the store, and a live plan (one whose directory carries a status.json marker) appears in neither population — never deleted and never counted as reclaimable.
  • manage-config/scripts/_config_defaults.py: build_results_days is derived from the same declared value as archived_plans_days rather than restating its literal, and _cmd_cleanup backfills an absent value from the project's effective setting, so a project that lengthens plan archival lengthens build-result retention in lock-step.

Ledger

  • manage-change-ledger/scripts/_ledger_core.py: build_record gains command / duration_seconds / outcome.
  • The executor template parses the wrapper's stdout TOON exactly once and threads it to all four consumers (status derivation plus the three new fields). A malformed payload still writes a row, with the three fields absent, and still never alters the dispatch's exit code.

Manifest

  • manage-execution-manifest/scripts/_manifest_core.py recognizes *.template files at the classifier aggregator — a generic rule, not a special case for the executor template.

Docs reconciled to what shipped: extension-api/standards/build-execution.md, build-systems-common.md, build-api-reference.md, doc/adr/002-Plan-scoped_operations_move_into_a_cwd-pinned_hermetic_worktree.adoc, tools-script-executor/standards/cwd-policy.md, and the manage-config / manage-run-config / marshall-steward surfaces for the new knob.

Worth a reviewer's attention

Three sweeps each found materially more than the outline claimed — and one of them had already been marked COMPLETE and was still a floor:

  • ADR-002 asserted "exactly FIVE" main-anchored corpora in five further places a first count-fix pass had missed, including the authoritative bullet list. cwd-policy.md carried the same split — its primary definitional statement said five with a five-item list while a later assertion in the same document already said six. A reader consults the former first.
  • add_project_dir_arg's docstring named two wrapper-local registrations; there are three. The omitted one is pyproject_build.py::_register_resolve_test_scope — precisely the site whose vacuous plan_id guard this change fixed.
  • Two "complete surface" claims over-claimed and were narrowed to what actually holds: the argparse-derived roster in test/_shared/_build_class_roster.py covers build_main-routed wrappers, while the executor treats every script under a build-* skill as build-class (js_coverage.py declares its own parser and sits outside the roster).

A live producer/consumer defect was found and fixed in build-server-client/scripts/build_server.py. _record_job wrote plan_id or None while _latest_job_id_for_plan matched plan_id or None verbatim. Resolving only the write half to the sentinel would have made every plan-less build's re-attach row unfindable — a silently lost running build, not an error. Both halves now store and match the sentinel verbatim.

A security pass added a path-containment guard. get_build_results_dir derives a directory from plan_id and now creates it and writes into it, but the chain get_build_results_dirget_plan_dirget_store_dir('plans', ...) performed no path-safety check — the asymmetry was visible inside get_store_dir itself, whose orchestrator branch calls _reject_unsafe_entry_id while its plans branch did not. Not exploitable today, but only because build_main routes real plan ids through a manage-status worktree lookup that happens to reject traversal. That is incidental, not deliberate: cache the worktree or short-circuit the lookup and the escape reopens silently. Containment-by-resolution is now applied at the documented single owner.

A simplify pass collapsed 8 hand-written copies of the plan_id and plan_id != NO_PLAN_SENTINEL guard (in two polarities, each with its own re-explanation comment) into names_real_plan(). The TypeGuard[str] return is load-bearing rather than decorative — the inline expression narrowed str | None to str for free, and a plain bool helper broke that in six places.

Explicitly NOT fixed by this PR

The false-fresh hole is out of scope and remains open. A zero-exit dispatch of a non-building build-class subcommand (discover, run-config-key) still stamps a kind=build row with status: success against the current worktree_sha, because _is_build_class_notation gates on the notation, not on whether a build ran. It is handed to PLAN-TRUTH-010, named in this plan's request as a serialization pair (same dispatch boundary, same kind=build row).

This change makes that hole read worse, not better: after these commits the bogus row carries a real plan_id instead of null, so it presents as that plan's successful build. Please do not read the never-null plan_id as evidence the row is trustworthy.

Also out of scope by design: no second or parallel build ledger (the existing kind=build entry was extended), and no deprecation shims or transitionary code (compatibility=breaking / simplicity=lean project defaults).

Test Plan

  • Verification command passed (python3 .plan/execute-script.py plan-marshall:build-pyproject:pyproject_build run --command-args "verify")
  • Fail-first tests for plan-id refusal, plan-scoped result placement (both a real plan id and NO_PLAN), the three new ledger fields, and a non-empty derived roster
  • Retention live-plan guard asserted on both surfaces — never deleted and never counted — plus non-live ageing, sentinel ageing with marker survival, dry-run, absent-tree no-op, target scoping, --target all inclusion, and the effective-value backfill (fixtures set archived_plans_days: 30, far from the shipped default, so a reader that fell back to the default fails rather than passing by coincidence)
  • A CLI test pins the rendered --target help against CLEANUP_TARGETS, so re-hardcoding the enum fails loudly
  • Verified live: with this plan holding 21 build-result files, cleanup --target build-results --dry-run reports zero reclaimable bytes and cleanup-status reports build_results_total: 0

Related Issues

Implements orchestrator spec PLAN-TRUTH-026 (truthful-signals epic). Serialization pair with PLAN-TRUTH-010 — see § Explicitly NOT fixed above.


Generated by plan-finalize skill

Intent

The problem. A build's plan_id was nullable, so a caller that forgot to pass one was indistinguishable from a genuinely plan-less build — an absence that could mean either. Build output landed in a shared .plan/temp/build-output/ tree, unattributable to the plan that caused it. And the kind=build ledger row recorded the executor argv but not the command the wrapper actually resolved and ran, nor the duration the wrapper already measured and then discarded at the ledger boundary.

The chosen approach. Make "no plan" a stated value rather than an absence, using the existing truthy NO_PLAN sentinel, so the ledger field can be never-null and every row is attributable. Then relocate results under {plan_dir}/build-results/ so the log is a plan artifact that moves with its plan, with the sentinel as the single main-anchored branch (a plan-less build belongs to no worktree). Then extend the existing kind=build row — not a second ledger — with the resolved command, the already-measured duration, and the wrapper's outcome TOON, keeping args (executor argv) and command (what the wrapper ran) as two distinct layers. The three are ordered by dependency: results cannot be plan-scoped until a plan id is guaranteed, and the ledger cannot point at a plan-scoped result until the result has moved.

Explicit non-goals. No second or parallel build ledger. No

[Intent truncated — 1389 of 1858 characters shown; full outline in the plan workspace]

Summary by CodeRabbit

  • New Features

    • Build logs are now stored in plan-specific build-results locations, with support for plan-less builds.
    • Added build-results cleanup with configurable retention, dry-run reporting, and live-plan protection.
    • Added consistent routing options across build commands, including project directory and root selection.
    • Template files are now classified based on their rendered targets.
    • Build records now retain command, duration, and outcome details.
  • Documentation

    • Updated build, cleanup, retention, routing, and plan-less operation guidance.

cuioss-oliver and others added 12 commits August 1, 2026 21:00
…esults directory

Build output now lands under {plan_dir}/build-results/{scope}/ so a build's logs
live with the plan that caused it, instead of accumulating in the shared
.plan/temp/build-output tree. get_build_results_dir in tools-file-ops is the
single owner of that path; the NO_PLAN sentinel is the one main-anchored branch,
because a plan-less build belongs to no worktree and cwd-relative resolution
would scatter its results across every worktree that ran one.

Co-Authored-By: Claude <noreply@anthropic.com>
…, and outcome

Record what actually ran, how long it took, and what it returned on the
existing kind=build ledger row. build_record now emits command,
duration_seconds, and outcome alongside the unchanged args field, so the
executor-argv layer and the wrapper-reported layer stay distinguishable.

The executor template sources command and duration from the build
wrapper's own reported payload rather than from the executor argv; a
malformed payload still produces a ledger row and still never changes
the dispatch's exit code.

Tests cover the three new fields plus args immutability, an end-to-end
dispatch asserting the row carries the wrapper-reported command and
duration, and the malformed-payload path. Also corrects the _build_entry
docstring in test_pre_commit_verify_freshness.py, which claimed to
mirror build_record's shape when it mirrors only the gate-relevant
subset (kind, status, worktree_sha).

Co-Authored-By: Claude <noreply@anthropic.com>
Deliverable 7: age out build results safely, never offering a running plan's
results for deletion.

D5 relocated build output into `{plan_dir}/build-results/`, which made it a
durable per-plan artifact but left it with no lifecycle: a real plan's results
are aged only once `archive-plan` moves the whole directory under
`archived-plans/`, and the `NO_PLAN` sentinel is never archived at all.

- `constants` gains `CLEANUP_TARGET_BUILD_RESULTS` and its member of the closed
  `CLEANUP_TARGETS` tuple, so the dispatcher and the argparse choices both gain
  the target from one edit.
- `_config_defaults` seeds `build_results_days` DERIVED from the same declared
  value as `archived_plans_days` rather than restating its literal, so the two
  shipped defaults cannot drift. `_cmd_cleanup.get_retention_settings`
  additionally backfills an absent `build_results_days` from the project's
  EFFECTIVE `archived_plans_days` — a project that lengthened plan archival
  lengthens build-result retention in lock-step. Membership is tested against
  the raw persisted mapping, not the normalized one, so an explicitly
  configured value always wins over the backfill.
- `_cmd_cleanup` gains the cleaner and its `get_status` counterpart. The plan
  set is ENUMERATED FROM THE STORE, never hand-listed, and a LIVE plan's tree
  is skipped entirely — a plan is live for as long as its directory carries a
  `status.json` marker, the same marker `require_plan_exists` and
  `manage-status list-orphans` already treat as the managed-plan signal. A live
  plan's results appear in NEITHER the cleanup nor the `cleanup-status`
  population, so they are never counted as reclaimable rather than merely
  spared deletion. Only a plan directory with no marker, and the never-archived
  `NO_PLAN` sentinel, are subject to the age threshold. Paths come from
  `file_ops.get_build_results_dir` — the declared single owner — so this
  surface cannot drift from the resolver the build wrappers write through.
- `run_config` needed no enum change (its choices already derive from
  `CLEANUP_TARGETS`); it gains the help-surface example for the new target.

Docs: the knob and the widened `--target` enum are mirrored into
manage-config data-model.md / api-reference.md / SKILL.md, manage-run-config
SKILL.md, and marshall-steward menu-maintenance.md, following the
`no-plan-bodies` precedent. data-model.md carries the canonical build-results
retention semantics; the other surfaces xref it. menu-maintenance.md's cleanup
output example was additionally corrected — it advertised a `deleted[N]` TOON
table the verb has never emitted.

Tests: the live-plan guard is asserted on BOTH surfaces (never deleted, never
counted), plus non-live ageing, sentinel ageing with marker survival, dry-run,
absent-tree no-op, target scoping, `--target all` inclusion, the effective-value
backfill, and explicit-wins-over-backfill. The backfill fixtures set
`archived_plans_days` to 30 — far from the shipped 5 — so a reader that fell
back to the shipped default fails instead of passing by coincidence. Fixtures
are written through `get_build_results_dir` itself, not a reconstructed literal.
A new CLI test pins the RENDERED `--target` help against `CLEANUP_TARGETS`, so
re-hardcoding the enum fails loudly.

Verified live: with this plan holding 21 build-result files,
`cleanup --target build-results --dry-run` reports zero reclaimable bytes and
`cleanup-status` reports `build_results_total: 0`.

Co-Authored-By: Claude <noreply@anthropic.com>
… contracts

Deliverable 8: bring the three published build extension-point standards back
in line with what D3, D4 and D5 actually shipped.

- `build-execution.md`: R1's log-file location and its component table now state
  the plan-scoped path, with `{plan_id}` as a first-class component and the
  `NO_PLAN` sentinel named once as part of the input contract rather than
  repeated per example. The rationale block is rewritten — the old one argued
  for `.plan/temp/` on gitignore/cleanup grounds that no longer apply; the new
  one states the durable-per-plan-artifact rationale, the worktree-move
  property, and the `build_results_days` retention that replaced temp cleanup.
  The `execute_direct()` parameter table gains `plan_id` AND the three
  parameters it had silently omitted (`env_vars`, `working_dir`,
  `explicit_timeout`), so the table stops under-reporting the signature it
  indexes; `plan_id` is documented as required keyword-only with no default,
  with the wrapper-must-forward-on-retry obligation stated. The
  execution-lifecycle diagram's `create_log_file` step was stale in two ways
  (it still showed a `project_dir` parameter the function no longer takes and
  omitted `plan_id`) and is corrected. Every TOON and JSON example and the
  persistence-points table now quote the plan-scoped path.
- `build-systems-common.md`: the Log File Pattern block states the plan-scoped
  path and xrefs R1 for the plan-scoping contract instead of restating it.
- `build-api-reference.md`: the two `run` output examples are re-pointed. The
  `--plan-id` description no longer presents the flag as optional-meaning-none
  — it is ALWAYS resolved, and omitting it selects the `NO_PLAN` sentinel, so
  the omission is an attribution decision (log placement and ledger stamping)
  rather than an absence. The subcommand availability table is completed
  against the argparse-derived roster: it omitted `run-config-key` (all four
  tools), maven's `rewrite-log`, and pyproject's `resolve-test-scope`, and so
  misreported the build-class surface as six subcommands when it is nine
  across 27 registrations. The universal `--plan-id` / `--project-dir` pair is
  stated once for the whole table rather than per row, matching its post-D3
  universality.

The one deliberate survivor of the retired `.plan/temp/build-output` literal is
`test/plan-marshall/tools-integration-ci/fixtures/ci-logs/SOURCE.md`, whose four
hits are dated capture-provenance records; rewriting them would falsify the
provenance rather than reconcile it.

Verified: `git grep build-output` over `extension-api/` returns nothing, and the
scoped plugin-doctor quality-gate reports `status: pass` with 0 findings across
all 31 rules.

Co-Authored-By: Claude <noreply@anthropic.com>
The NO_PLAN sentinel's build-results directory joins merge.lock,
run-configuration.json, lessons-learned/, merge-queue.json, and
orchestrator/ as a per-repo cross-session corpus resolved through
resolve_main_anchored_path. Three count claims in ADR-002 stated five.

Only the sentinel's build results are main-anchored; a real plan's
build-results/ sits inside its plan directory and moves with it. Phrasing
aligned with the same clarification already carried in cwd-policy.md.

Co-Authored-By: Claude <noreply@anthropic.com>
…laims

Pre-submission self-review findings, all four fixed:

1. ADR-002 said "exactly FIVE" main-anchored corpora in five further
   places the earlier count fix missed, including the authoritative
   bullet list. Added the sixth member and reconciled the heading and
   the four prose references.
2. cwd-policy.md carried the same split: its primary definitional
   statement said five with a five-item list while its later assertion
   already said six. A reader consults the former first.
3. add_project_dir_arg's docstring named two wrapper-local
   registrations; there are three. The omitted one is
   pyproject_build.py::_register_resolve_test_scope, whose vacuous
   plan_id guard this change fixed.
4. The roster-universality guarantee and build-api-reference's
   "COMPLETE build-class surface" both over-claimed: the roster covers
   build_main-routed wrappers, while the executor treats every script
   under a build-* skill as build-class. js_coverage.py declares its own
   parser and is outside the roster. Both claims narrowed to what holds.

Co-Authored-By: Claude <noreply@anthropic.com>
The NO_PLAN sentinel is truthy, so every "is this build plan-bound?"
site spelled out `plan_id and plan_id != NO_PLAN_SENTINEL` (or its
inverse) plus a comment re-explaining why falsiness alone is not
enough. Eight sites, eight copies of the reasoning, two polarities.

Adds names_real_plan() next to the sentinel it interprets and routes
all eight guards through it. The TypeGuard[str] return is load-bearing
rather than decorative: the inline expression narrowed str | None to
str for free, and a plain bool helper broke that in six places.

Also drops the unused help_text parameter from add_root_arg — both
call sites use the default.

Co-Authored-By: Claude <noreply@anthropic.com>
get_build_results_dir derives a directory from plan_id and now creates
it and writes into it. The chain get_build_results_dir -> get_plan_dir
-> get_store_dir('plans', ...) performed no path-safety check, and the
asymmetry was visible in get_store_dir itself: its orchestrator branch
calls _reject_unsafe_entry_id, its plans branch did not.

Not exploitable today — build_main routes every real plan id through
resolve_plan_context, whose manage-status lookup rejects traversal via
validate_plan_id. But that is an incidental side effect of a worktree
lookup, not a deliberate path check: cache the worktree face or
short-circuit the lookup and the escape reopens silently.

Adds containment-by-resolution at the documented single owner, so it
also catches a plan directory symlinked out of the store and never
false-positives on a contained but odd directory name from the GC
enumerator. The unresolved path is still returned, preserving
create_log_file's unresolved_base semantics.

Also documents the secrets discipline on build_record: the kind=build
row now retains a secret-capable command/outcome while the sibling
build_server._audit_log explicitly refuses that data class. Accepted
here (the ledger is gitignored and the same text is co-resident in the
log the row points at), but the log ages out under build_results_days
while the ledger is append-only, so the excerpt outlives its source.

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

@sourcery-ai sourcery-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Sorry @cuioss-oliver, your pull request is larger than the review limit of 150000 diff characters

@coderabbitai

coderabbitai Bot commented Aug 1, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

This change introduces NO_PLAN sentinel attribution, plan-scoped build-result storage, build-result cleanup retention, expanded routing flags, wrapper ledger fields, and template path classification.

Changes

Build state and execution flow

Layer / File(s) Summary
Routing and plan identity contracts
marketplace/bundles/plan-marshall/skills/script-shared/scripts/*
Shared routing resolves --root, --project-dir, and --plan-id. NO_PLAN is excluded from real-plan operations.
Execution and ledger propagation
marketplace/bundles/plan-marshall/skills/script-shared/scripts/build/*, marketplace/bundles/plan-marshall/skills/manage-change-ledger/scripts/*, marketplace/bundles/plan-marshall/skills/tools-script-executor/templates/*
Execution forwards plan_id. Ledger records use NO_PLAN when absent and preserve wrapper command, duration, outcome, and executor arguments.
Build-result storage and cleanup
marketplace/bundles/plan-marshall/skills/tools-file-ops/scripts/*, marketplace/bundles/plan-marshall/skills/manage-run-config/scripts/*, marketplace/bundles/plan-marshall/skills/manage-config/*
Build results move to plan-specific directories. NO_PLAN results remain main-anchored. Cleanup supports retention, dry runs, status metrics, and live-plan protection.
Template path classification
marketplace/bundles/plan-marshall/skills/manage-execution-manifest/*
.template files are classified through their stripped render targets. Unrecognized targets default to production.

Estimated code review effort: 5 (Critical) | ~120 minutes

Possibly related PRs

🚥 Pre-merge checks | ✅ 2
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main changes: mandatory plan IDs, relocated build results, and extended ledger data.

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@cuioss-review-bot

Copy link
Copy Markdown

PR Reviewer Guide 🔍

🧪 PR contains tests
🔒 No security concerns identified
⚡ No major issues detected

@cuioss-oliver

Copy link
Copy Markdown
Collaborator Author

Triage dispositions

In reply to comment_id: IC_kwDOQ3xasM8AAAABMyiCaw

No actionable defect. This is PR-Agent's PR Reviewer Guide summary table - PR contains tests / No security concerns identified / No major issues detected. It names no file, line, rule or suggested change, so it matches no FIX-eligible category in ext-triage-plugin pr-comment-disposition and no fix task is warranted. Recorded with the coverage caveat stated explicitly rather than read as a clean review: PR-Agent was the only one of three configured review bots that actually reviewed this PR. CodeRabbit declined as rate-limited (classified refused_awaitable) while its check-run still reported completed - a green check is not evidence of review - and Sourcery declined on quota (refused_hard). SonarCloud has no analysis of this PR at all: 404 on PR decoration, no CE task since 2026-06-29, and no sonar workflow in the repo, so the zero sonar-issue count is confirmed-but-vacuous rather than analyzed-and-clean. This disposition therefore closes one bot's content-free summary and is NOT evidence of a multi-bot clean sweep.

The two-idiom split this plan established existed only as a docstring on
one function, while the obligation it creates binds every future consumer
of an optional identity across many skills.

ADR-015 records both halves as one decision: an absent identity is a
stated sentinel value so every record is attributable, and because that
sentinel is truthy, every predicate that tested presence must be
re-expressed as a meaning test through one named TypeGuard. Adopting the
first half without the second reintroduces the vacuous-guard class the
change set out to remove.

The residual failure mode is recorded as accepted rather than solved: a
truthy sentinel silently satisfies any new bare truthiness guard, and the
type system cannot detect it, so the seam is a review convention rather
than a compiler guarantee.

Related to ADR-002 and ADR-009, superseding neither.

Co-Authored-By: Claude <noreply@anthropic.com>
@cuioss-oliver
cuioss-oliver added this pull request to the merge queue Aug 2, 2026

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 8

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
marketplace/bundles/plan-marshall/skills/extension-api/standards/build-api-reference.md (1)

28-42: 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Derive build-class membership from one authoritative source.

The API table fixes the wrapper population at four. The ledger classifier relies on the fixed _BUILD_CLASS_PREFIXES list. A newly registered build-* skill can be absent from the documentation and skip kind=build ledger records.

  • marketplace/bundles/plan-marshall/skills/extension-api/standards/build-api-reference.md#L28-L42: generate the wrapper roster from the argparse-derived build-class roster during documentation generation.
  • marketplace/bundles/plan-marshall/skills/tools-script-executor/templates/execute-script.py.template#L978-L988: derive build-class classification from the registered notation’s build-skill segment instead of _BUILD_CLASS_PREFIXES.

As per path instructions, a hardcoded list that mirrors registered handlers must be derived from its authoritative source. Based on learnings, derive detector populations from their authoritative source.

Sources: Path instructions, Learnings

🧹 Nitpick comments (5)
marketplace/bundles/plan-marshall/skills/manage-run-config/SKILL.md (1)

397-397: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Derive or validate the canonical cleanup target list.

This literal choice list must mirror CLEANUP_TARGETS in marketplace/bundles/plan-marshall/skills/tools-file-ops/scripts/constants.py. It can drift when a target is added or removed. Generate this block from the authoritative tuple, or add a non-vacuous contract test that compares the documented choices with CLEANUP_TARGETS.

Based on learnings, guarded populations should come from the authoritative source and should not pass trivially over an empty set.

Sources: Path instructions, Learnings

test/plan-marshall/script-shared/test_build_execute.py (1)

512-524: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

Tighten the pytest.raises(TypeError) assertion.

The bare TypeError match accepts any TypeError raised anywhere inside execute_direct_base. If plan_id ever regains a default, the call proceeds past argument binding: it is unmocked here, so it reaches subprocess.run with wrapper='/usr/bin/test-tool' and create_log_file. An unrelated TypeError from that path would keep this test green while the required-argument contract is gone.

Add a match on the parameter name so the assertion pins the missing-argument case.

♻️ Proposed change
     def test_plan_id_is_required(self):
         """Omitting ``plan_id`` is a TypeError — the attribution has no default."""
-        with pytest.raises(TypeError):
+        with pytest.raises(TypeError, match='plan_id'):
             execute_direct_base(
test/plan-marshall/script-shared/test_build_execute_factory.py (1)

47-47: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Both suites read NO_PLAN_SENTINEL from a test fixtures module, not from production. test/_shared/_resolve_project_dir_fixtures.py defines NO_PLAN_SENTINEL as its own string literal rather than re-exporting the production constant from marketplace_paths. Every sentinel assertion in these two files therefore compares production behavior against a test-local copy of the value. test/plan-marshall/script-shared/test_build_execute.py already imports the constant from marketplace_paths, so the suite is inconsistent about its source of truth. A divergence fails these tests loudly, so this is a duplicated-constant concern, not a false-pass risk.

  • test/plan-marshall/script-shared/test_build_execute_factory.py#L47-L47: replace the _resolve_project_dir_fixtures import with from marketplace_paths import NO_PLAN_SENTINEL.
  • test/plan-marshall/build-server/test_build_server_client.py#L21-L21: replace the _resolve_project_dir_fixtures import with from marketplace_paths import NO_PLAN_SENTINEL.

As a follow-up, consider re-exporting the production constant from _resolve_project_dir_fixtures so the fixtures module stops carrying its own literal.

Source: Learnings

test/plan-marshall/tools-script-executor/test_execute_script.py (2)

589-589: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Derive the build-class notation from build_class_prefixes().

_BUILD_CLASS_NOTATION hardcodes a notation that must stay inside the executor template's _BUILD_CLASS_PREFIXES. This PR adds test/_shared/_build_class_roster.py, which exposes build_class_prefixes() as the derived read of that same declaration. Build the notation from that source so a prefix rename moves the fixture with it.

The current shape fails loudly rather than silently: if the notation stops being build-class, no kind=build row is written and the assert ledger.is_file() on line 641 fails. Treat this as hardening, not a defect.

Sources: Path instructions, Learnings


765-772: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

Assert the missing outcome field.

The docstring says an unparseable payload leaves the three wrapper-sourced fields absent, but this test only pins command and duration_seconds. Add assert entry['outcome'] is None to fully cover the stated degraded ledger shape.

♻️ Proposed change
     # No wrapper facts are invented from an unusable payload.
     assert entry['command'] is None
     assert entry['duration_seconds'] is None
+    assert entry['outcome'] is None

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository: cuioss/coderabbit/.coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: cc0867cb-acb7-49e2-b0dc-539a4ca63bbe

📥 Commits

Reviewing files that changed from the base of the PR and between 8db7b42 and 67b25ab.

📒 Files selected for processing (74)
  • doc/adr/002-Plan-scoped_operations_move_into_a_cwd-pinned_hermetic_worktree.adoc
  • doc/adr/015-An_absent_identity_is_a_stated_sentinel_value_and_every_presence_guard_becomes_a_meaning_guard.adoc
  • marketplace/bundles/plan-marshall/skills/build-gradle/scripts/_gradle_cmd_discover.py
  • marketplace/bundles/plan-marshall/skills/build-gradle/scripts/gradle.py
  • marketplace/bundles/plan-marshall/skills/build-maven/scripts/_maven_cmd_discover.py
  • marketplace/bundles/plan-marshall/skills/build-maven/scripts/maven.py
  • marketplace/bundles/plan-marshall/skills/build-pyproject/scripts/_pyproject_execute.py
  • marketplace/bundles/plan-marshall/skills/build-pyproject/scripts/pyproject_build.py
  • marketplace/bundles/plan-marshall/skills/build-server-client/SKILL.md
  • marketplace/bundles/plan-marshall/skills/build-server-client/scripts/build_server.py
  • marketplace/bundles/plan-marshall/skills/extension-api/standards/build-api-reference.md
  • marketplace/bundles/plan-marshall/skills/extension-api/standards/build-execution.md
  • marketplace/bundles/plan-marshall/skills/extension-api/standards/build-systems-common.md
  • marketplace/bundles/plan-marshall/skills/manage-change-ledger/SKILL.md
  • marketplace/bundles/plan-marshall/skills/manage-change-ledger/scripts/_ledger_core.py
  • marketplace/bundles/plan-marshall/skills/manage-change-ledger/scripts/manage-change-ledger.py
  • marketplace/bundles/plan-marshall/skills/manage-config/SKILL.md
  • marketplace/bundles/plan-marshall/skills/manage-config/scripts/_config_defaults.py
  • marketplace/bundles/plan-marshall/skills/manage-config/standards/api-reference.md
  • marketplace/bundles/plan-marshall/skills/manage-config/standards/data-model.md
  • marketplace/bundles/plan-marshall/skills/manage-execution-manifest/scripts/_manifest_core.py
  • marketplace/bundles/plan-marshall/skills/manage-execution-manifest/scripts/manage-execution-manifest.py
  • marketplace/bundles/plan-marshall/skills/manage-execution-manifest/standards/decision-rules.md
  • marketplace/bundles/plan-marshall/skills/manage-locks/SKILL.md
  • marketplace/bundles/plan-marshall/skills/manage-locks/scripts/merge_lock.py
  • marketplace/bundles/plan-marshall/skills/manage-run-config/SKILL.md
  • marketplace/bundles/plan-marshall/skills/manage-run-config/scripts/_cmd_cleanup.py
  • marketplace/bundles/plan-marshall/skills/manage-run-config/scripts/run_config.py
  • marketplace/bundles/plan-marshall/skills/manage-tasks/SKILL.md
  • marketplace/bundles/plan-marshall/skills/marshall-steward/references/menu-maintenance.md
  • marketplace/bundles/plan-marshall/skills/script-shared/scripts/build/_build_cli.py
  • marketplace/bundles/plan-marshall/skills/script-shared/scripts/build/_build_execute.py
  • marketplace/bundles/plan-marshall/skills/script-shared/scripts/build/_build_execute_factory.py
  • marketplace/bundles/plan-marshall/skills/script-shared/scripts/build/_build_queue_slot.py
  • marketplace/bundles/plan-marshall/skills/script-shared/scripts/build/_build_result.py
  • marketplace/bundles/plan-marshall/skills/script-shared/scripts/build/_build_server_protocol.py
  • marketplace/bundles/plan-marshall/skills/script-shared/scripts/build/_build_shared.py
  • marketplace/bundles/plan-marshall/skills/script-shared/scripts/marketplace_paths.py
  • marketplace/bundles/plan-marshall/skills/script-shared/scripts/resolve_project_dir.py
  • marketplace/bundles/plan-marshall/skills/tools-file-ops/scripts/constants.py
  • marketplace/bundles/plan-marshall/skills/tools-file-ops/scripts/file_ops.py
  • marketplace/bundles/plan-marshall/skills/tools-script-executor/standards/cwd-policy.md
  • marketplace/bundles/plan-marshall/skills/tools-script-executor/templates/execute-script.py.template
  • test/_shared/_build_class_roster.py
  • test/_shared/_resolve_project_dir_fixtures.py
  • test/plan-marshall/build-gradle/test_gradle_execute.py
  • test/plan-marshall/build-gradle/test_gradle_find_project_command.py
  • test/plan-marshall/build-gradle/test_gradle_run.py
  • test/plan-marshall/build-maven/test_maven.py
  • test/plan-marshall/build-maven/test_maven_execute.py
  • test/plan-marshall/build-maven/test_maven_run.py
  • test/plan-marshall/build-npm/test_npm.py
  • test/plan-marshall/build-npm/test_npm_execute.py
  • test/plan-marshall/build-npm/test_npm_run.py
  • test/plan-marshall/build-operations/test_timeout_truthful_evidence.py
  • test/plan-marshall/build-pyproject/test_pyproject_build.py
  • test/plan-marshall/build-pyproject/test_pyproject_execute.py
  • test/plan-marshall/build-server/test_build_server_client.py
  • test/plan-marshall/manage-change-ledger/test_manage_change_ledger.py
  • test/plan-marshall/manage-config/test_cache_retention_knobs.py
  • test/plan-marshall/manage-execution-manifest/test_classify_paths_via_extensions.py
  • test/plan-marshall/manage-run-config/test_cleanup.py
  • test/plan-marshall/manage-tasks/test_pre_commit_verify_freshness.py
  • test/plan-marshall/plan-marshall/test_worktree_contract_e2e.py
  • test/plan-marshall/script-shared/test_build_cli.py
  • test/plan-marshall/script-shared/test_build_execute.py
  • test/plan-marshall/script-shared/test_build_execute_factory.py
  • test/plan-marshall/script-shared/test_build_format.py
  • test/plan-marshall/script-shared/test_build_queue_slot.py
  • test/plan-marshall/script-shared/test_build_result.py
  • test/plan-marshall/script-shared/test_build_shared.py
  • test/plan-marshall/script-shared/test_build_timeout_truthfulness.py
  • test/plan-marshall/tools-file-ops/test_file_ops.py
  • test/plan-marshall/tools-script-executor/test_execute_script.py


**The never-steal-a-claim invariant.** The infrastructure-config rule is a fallback, never a pre-filter. Running it over the residual unclaimed set — rather than ahead of the build extensions — makes stealing structurally impossible: `build-maven` legitimately claims `src/main/resources/application.yml` as `production`, and a stage-1 placement would strip that path from the extensions' view and silently downgrade a Maven production resource to `config`. This is why the documentation and infrastructure-config rules sit on opposite sides of the build-extension stage; the template rule sits on the same side as infrastructure config, and for the same reason.

No rule ever assigns `test`, and only the template rule's fail-closed terminal assigns `production` — deliberately, because an unrecognized render target must not be excused from verification. No route is added anywhere: `build.map` is byte-identical for every project, and an owner-less path triggers no build gate. The build extensions (`build-pyproject` / `build-maven` / `build-gradle` / `build-npm`) remain the sole source of production / test / config **claims**; the generic rules are the sole source of owner-less recognition. The six-bucket vocabulary is unchanged — `config` never influences the plan-wide bucket, so an infra-only footprint collapses to `documentation_only` and an infra-plus-code footprint keeps the code bucket.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Remove the hardcoded build-extension roster.

Line 321 lists four build extensions. The authoritative population is extension_discovery.discover_build_extensions(). The document can drift when a build extension is registered or removed.

Refer to dynamically discovered BuildExtensionBase implementations without enumerating them here.

As per path instructions, “Treat a hardcoded list that must mirror a set defined elsewhere — build goals, enum constants, registered handlers, dispatch tables — as a defect unless it is derived from that source at build or run time.”

Source: Path instructions

Comment on lines +322 to +359
def cleanable_build_results_dirs() -> list[tuple[str, Path]]:
"""Return ``(plan_id, build_results_dir)`` for every NON-live results tree.

The plan set is ENUMERATED FROM THE PLAN STORE, never from a hand-written
list, so a newly created plan is covered without an edit here. Each entry's
path comes from :func:`file_ops.get_build_results_dir` — the single owner of
the build-results path — so this cleanup surface cannot drift away from the
resolver the build wrappers actually write through.

Two populations survive the filter:

* A plan directory whose ``status.json`` is absent (see :func:`plan_is_live`)
— a leftover the plan lifecycle no longer tracks.
* The ``NO_PLAN`` sentinel, unconditionally. It is shared and permanent —
never archived, exactly the condition that made the ``no-plan-bodies``
target necessary — so an age threshold is the only lifecycle its build
results can have. It is appended rather than discovered because its
results are main-anchored (``get_build_results_dir`` resolves the sentinel
through the main checkout), so a caller pinned to a worktree would
otherwise never see it.

A live plan appears in NEITHER the cleanup nor the ``cleanup-status``
population: its build results are not merely spared deletion, they are never
counted as reclaimable.
"""
cleanable: list[tuple[str, Path]] = []

plans_dir = PLAN_BASE_DIR / DIR_PLANS
if plans_dir.is_dir():
for plan_dir in sorted(plans_dir.iterdir()):
if not plan_dir.is_dir() or plan_dir.name == NO_PLAN_SENTINEL:
continue
if plan_is_live(plan_dir):
continue
cleanable.append((plan_dir.name, get_build_results_dir(plan_dir.name)))

cleanable.append((NO_PLAN_SENTINEL, get_build_results_dir(NO_PLAN_SENTINEL)))
return cleanable

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Guard cleanable_build_results_dirs() against get_build_results_dir raising.

get_build_results_dir(plan_dir.name) can raise ValueError when the resolved directory escapes the plans/ root — for example, a plan directory symlinked outside the tree. Unlike this same call path's own docstring, which says production callers are protected by an upstream validate_plan_id, this call site iterates real on-disk directories directly and has no such upstream guard.

Line 356 has no try/except around the call, so one anomalous plan directory raises out of cleanable_build_results_dirs(), through clean_build_results() or get_status(), and aborts the entire cleanup/cleanup-status command with an unstructured traceback — even though temp, logs, archived-plans, and no-plan-bodies cleanup may have already completed. Sibling helpers in this same file (build_result_files, clean_no_plan_bodies) already catch per-item OSError to keep going; this call should follow the same pattern.

🐛 Proposed fix to skip an unsafe plan directory instead of crashing
             if plan_is_live(plan_dir):
                 continue
-            cleanable.append((plan_dir.name, get_build_results_dir(plan_dir.name)))
+            try:
+                cleanable.append((plan_dir.name, get_build_results_dir(plan_dir.name)))
+            except ValueError:
+                continue
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
def cleanable_build_results_dirs() -> list[tuple[str, Path]]:
"""Return ``(plan_id, build_results_dir)`` for every NON-live results tree.
The plan set is ENUMERATED FROM THE PLAN STORE, never from a hand-written
list, so a newly created plan is covered without an edit here. Each entry's
path comes from :func:`file_ops.get_build_results_dir`the single owner of
the build-results pathso this cleanup surface cannot drift away from the
resolver the build wrappers actually write through.
Two populations survive the filter:
* A plan directory whose ``status.json`` is absent (see :func:`plan_is_live`)
a leftover the plan lifecycle no longer tracks.
* The ``NO_PLAN`` sentinel, unconditionally. It is shared and permanent
never archived, exactly the condition that made the ``no-plan-bodies``
target necessaryso an age threshold is the only lifecycle its build
results can have. It is appended rather than discovered because its
results are main-anchored (``get_build_results_dir`` resolves the sentinel
through the main checkout), so a caller pinned to a worktree would
otherwise never see it.
A live plan appears in NEITHER the cleanup nor the ``cleanup-status``
population: its build results are not merely spared deletion, they are never
counted as reclaimable.
"""
cleanable: list[tuple[str, Path]] = []
plans_dir = PLAN_BASE_DIR / DIR_PLANS
if plans_dir.is_dir():
for plan_dir in sorted(plans_dir.iterdir()):
if not plan_dir.is_dir() or plan_dir.name == NO_PLAN_SENTINEL:
continue
if plan_is_live(plan_dir):
continue
cleanable.append((plan_dir.name, get_build_results_dir(plan_dir.name)))
cleanable.append((NO_PLAN_SENTINEL, get_build_results_dir(NO_PLAN_SENTINEL)))
return cleanable
def cleanable_build_results_dirs() -> list[tuple[str, Path]]:
"""Return ``(plan_id, build_results_dir)`` for every NON-live results tree.
The plan set is ENUMERATED FROM THE PLAN STORE, never from a hand-written
list, so a newly created plan is covered without an edit here. Each entry's
path comes from :func:`file_ops.get_build_results_dir`the single owner of
the build-results pathso this cleanup surface cannot drift away from the
resolver the build wrappers actually write through.
Two populations survive the filter:
* A plan directory whose ``status.json`` is absent (see :func:`plan_is_live`)
a leftover the plan lifecycle no longer tracks.
* The ``NO_PLAN`` sentinel, unconditionally. It is shared and permanent
never archived, exactly the condition that made the ``no-plan-bodies``
target necessaryso an age threshold is the only lifecycle its build
results can have. It is appended rather than discovered because its
results are main-anchored (``get_build_results_dir`` resolves the sentinel
through the main checkout), so a caller pinned to a worktree would
otherwise never see it.
A live plan appears in NEITHER the cleanup nor the ``cleanup-status``
population: its build results are not merely spared deletion, they are never
counted as reclaimable.
"""
cleanable: list[tuple[str, Path]] = []
plans_dir = PLAN_BASE_DIR / DIR_PLANS
if plans_dir.is_dir():
for plan_dir in sorted(plans_dir.iterdir()):
if not plan_dir.is_dir() or plan_dir.name == NO_PLAN_SENTINEL:
continue
if plan_is_live(plan_dir):
continue
try:
cleanable.append((plan_dir.name, get_build_results_dir(plan_dir.name)))
except ValueError:
continue
cleanable.append((NO_PLAN_SENTINEL, get_build_results_dir(NO_PLAN_SENTINEL)))
return cleanable

Comment on lines +254 to 255
| `build-results` | `build_results_days` | Aged build-result files under a NON-live plan's `build-results/` tree, and under the `NO_PLAN` sentinel's |

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Complete the build-results target description.

The table row ends with “under the NO_PLAN sentinel's” and does not identify the path or item being cleaned. Document the exact target, such as .plan/local/plans/NO_PLAN/build-results/, and state whether the cleanup removes files, directories, or both.

Proposed documentation fix
-| `build-results` | `build_results_days` | Aged build-result files under a NON-live plan's `build-results/` tree, and under the `NO_PLAN` sentinel's |
+| `build-results` | `build_results_days` | Aged build-result files under a NON-live plan's `build-results/` tree and under `.plan/local/plans/NO_PLAN/build-results/`. |

Comment on lines +162 to 177
**Output (TOON)** — a flat per-target count/bytes pair, with `status: dry_run` instead of `success` under `--dry-run`:
```toon
status: success
operation: cleanup
dry_run: false

deleted[4]{category,count,size_bytes}:
logs 3 512
archived_plans 2 8192
temp 5 1024
no_plan_bodies 2 4096
target: all
temp_files: 5
temp_bytes: 1024
logs_deleted: 3
logs_bytes: 512
archived_plans_deleted: 2
archived_plans_bytes: 8192
no_plan_bodies_deleted: 2
no_plan_bodies_bytes: 4096
build_results_deleted: 4
build_results_bytes: 16384
total_bytes_freed: 29824
```

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Fix the arithmetic in the example total_bytes_freed.

The listed byte fields sum to 1024 + 512 + 8192 + 4096 + 16384 = 30208, but the example shows total_bytes_freed: 29824. Correct the example so the total matches the sum of its components.

📝 Proposed fix for the example total
-total_bytes_freed: 29824
+total_bytes_freed: 30208
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
**Output (TOON)** — a flat per-target count/bytes pair, with `status: dry_run` instead of `success` under `--dry-run`:
```toon
status: success
operation: cleanup
dry_run: false
deleted[4]{category,count,size_bytes}:
logs 3 512
archived_plans 2 8192
temp 5 1024
no_plan_bodies 2 4096
target: all
temp_files: 5
temp_bytes: 1024
logs_deleted: 3
logs_bytes: 512
archived_plans_deleted: 2
archived_plans_bytes: 8192
no_plan_bodies_deleted: 2
no_plan_bodies_bytes: 4096
build_results_deleted: 4
build_results_bytes: 16384
total_bytes_freed: 29824
```
**Output (TOON)** — a flat per-target count/bytes pair, with `status: dry_run` instead of `success` under `--dry-run`:

Comment on lines +392 to +394
plan_id: The submitting plan id — the ``NO_PLAN`` sentinel for a
plan-less build (the client resolves it before constructing the
spec, so the wire value is never the empty string).

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Validate the plan_id wire invariant in JobSpec.from_dict.

The updated contract says that plan_id is never empty. from_dict still accepts "" and converts non-string values such as None to strings. Reject non-string or empty values before constructing JobSpec.

Proposed fix
         command = data['command']
         if not isinstance(command, list) or not all(
             isinstance(token, str) for token in command
         ):
             raise ValueError('job spec command must be a list of strings')
+        plan_id = data['plan_id']
+        if not isinstance(plan_id, str) or not plan_id:
+            raise ValueError('job spec plan_id must be a non-empty string')
         return cls(
             command=list(command),
             exec_path=str(data['exec_path']),
             project_path=str(data['project_path']),
-            plan_id=str(data['plan_id']),
+            plan_id=plan_id,
             fingerprint=str(data.get('fingerprint', '')),
         )

As per path instructions, a stated validation guarantee needs an implementation mechanism.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
plan_id: The submitting plan idthe ``NO_PLAN`` sentinel for a
plan-less build (the client resolves it before constructing the
spec, so the wire value is never the empty string).
command = data['command']
if not isinstance(command, list) or not all(
isinstance(token, str) for token in command
):
raise ValueError('job spec command must be a list of strings')
plan_id = data['plan_id']
if not isinstance(plan_id, str) or not plan_id:
raise ValueError('job spec plan_id must be a non-empty string')
return cls(
command=list(command),
exec_path=str(data['exec_path']),
project_path=str(data['project_path']),
plan_id=plan_id,
fingerprint=str(data.get('fingerprint', '')),
)

Source: Path instructions

Comment on lines +25 to 34
**There is ONE sanctioned main-anchored resolver utility, used by a bounded set of six cross-session consumers.** `resolve_main_anchored_path` in `script-shared/marketplace_paths.py` is the single deliberate exception to cwd-relative resolution: it resolves a subpath under the MAIN checkout's `.plan/local` regardless of caller cwd (test override first, then `git rev-parse --git-common-dir`). It is consumed by exactly six genuinely-shared cross-session corpora, each reached through that one utility:

- `merge.lock` — the cooperative advisory lock acquired by `integrate_into_main.py` before move-back/merge (`merge_lock.py`);
- `run-configuration.json` — the adaptive-timeout corpus (`manage-run-config/run_config.py`);
- `lessons-learned` — the global lessons corpus, including the id-allocation `plans` scan (`manage-lessons/manage-lessons.py`);
- `merge-queue.json` — the cross-worktree merge-admission queue (`manage-locks`);
- `orchestrator` — the epic-orchestration store (`tools-file-ops/file_ops.py` `get_store_dir('orchestrator', entry_id)`), cross-plan state that outlives any single plan's worktree.
- `orchestrator` — the epic-orchestration store (`tools-file-ops/file_ops.py` `get_store_dir('orchestrator', entry_id)`), cross-plan state that outlives any single plan's worktree;
- `plans/NO_PLAN/build-results` — the build results of plan-less builds (`tools-file-ops/file_ops.py` `get_build_results_dir`). Only the `NO_PLAN` sentinel's build results are main-anchored; a real plan's `build-results/` sits inside its plan directory and therefore moves with it.

The utility is the ONE mechanism; the consuming scripts CALL it rather than each carrying its own git-common-dir copy. Every other **per-repo** resolution is cwd-relative. New **per-repo** cross-session shared state MUST route through this utility, not re-implement git-common-dir resolution. The worktree's `.plan/local` is a fully REAL directory with NO symlinks — cross-session visibility comes from the utility, not from a filesystem symlink. (Machine-**wide**, cross-repo state belongs instead to the machine-global home-root tier — see below.)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift

Make the main-anchored consumer boundary executable.

The same root cause appears at all listed sites. They claim that resolve_main_anchored_path has a bounded six-consumer set, but the supplied resolver accepts arbitrary subpaths. Add the authoritative allowlist or dedicated wrappers in marketplace/bundles/plan-marshall/skills/script-shared/scripts/marketplace_paths.py, then make the documentation reference that source.

  • marketplace/bundles/plan-marshall/skills/tools-script-executor/standards/cwd-policy.md#L25-L34: enforce and source the six-consumer list.
  • marketplace/bundles/plan-marshall/skills/manage-locks/SKILL.md#L130-L131: reference the authoritative exception set.
  • marketplace/bundles/plan-marshall/skills/manage-locks/scripts/merge_lock.py#L138-L139: reference the authoritative exception set.
  • marketplace/bundles/plan-marshall/skills/tools-script-executor/standards/cwd-policy.md#L52-L52: keep the shared-state list derived from the same source.
  • marketplace/bundles/plan-marshall/skills/tools-script-executor/standards/cwd-policy.md#L75-L79: keep the assertion tied to the executable boundary.

As per path instructions, a stated boundary must have an executable mechanism.

📍 Affects 3 files
  • marketplace/bundles/plan-marshall/skills/tools-script-executor/standards/cwd-policy.md#L25-L34 (this comment)
  • marketplace/bundles/plan-marshall/skills/manage-locks/SKILL.md#L130-L131
  • marketplace/bundles/plan-marshall/skills/manage-locks/scripts/merge_lock.py#L138-L139
  • marketplace/bundles/plan-marshall/skills/tools-script-executor/standards/cwd-policy.md#L52-L52
  • marketplace/bundles/plan-marshall/skills/tools-script-executor/standards/cwd-policy.md#L75-L79

Source: Path instructions

Comment on lines +345 to +363
def test_ledger_core_constructors_declare_plan_id_as_required_str() -> None:
"""``build_record`` / ``job_record`` no longer accept ``str | None``.

The clean break is asserted on the SIGNATURE rather than on behaviour: the
``| None`` union is removed outright (not deprecated), which is what makes a
caller that still passes ``None`` a type error rather than a silently-null
row. Behaviour-only assertions cannot see this, because Python would happily
store the ``None`` either way.
"""
import inspect

import _ledger_core

for constructor in (_ledger_core.build_record, _ledger_core.job_record):
annotation = inspect.signature(constructor).parameters['plan_id'].annotation
assert annotation == 'str', (
f'{constructor.__name__} declares plan_id as {annotation!r}; the '
'str | None union must be removed, not deprecated.'
)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail
fd -a '_ledger_core.py' | xargs -I{} head -n 10 {}
fd -a 'pyproject.toml' -d 2 | xargs -I{} rg -n 'requires-python|python_requires' {}

Repository: cuioss/plan-marshall

Length of output: 654


🏁 Script executed:

#!/bin/bash
set -euo pipefail
python3 - <<'PY'
import inspect

class C:
    def f(self, plan_id: str = "x") -> None: ...

sig = inspect.signature(C.f)
param = sig.parameters["plan_id"]
print("runtime:", repr(param.annotation))
print("callable inspect signature:", callable(param.annotation))
print("str annotation equality:", param.annotation == "str")
print("type annotation equality:", param.annotation == str)
print("annotation is class str:", param.annotation is str)
PY

Repository: cuioss/plan-marshall

Length of output: 306


Check _ledger_core’s actual annotation form at runtime.

_ledger_core.py has no future annotations import, and this project targets Python >=3.12; inspect.signature().parameters['plan_id'].annotation returns <class 'str'>, so the annotation == 'str' assertion will fail on Python 3.12+. Use annotation is str or a comparison that accounts for the actual annotation type.

)
return calls

@pytest.mark.parametrize('plan_id', [None, '', 'NO_PLAN'])

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Import NO_PLAN_SENTINEL instead of hardcoding 'NO_PLAN'.

Every other test file in this PR that exercises the sentinel (test_build_result.py, test_build_queue_slot.py, test_manage_change_ledger.py, test_pyproject_execute.py) imports NO_PLAN_SENTINEL from marketplace_paths and parametrizes on the constant. Lines 297 and 309 hardcode the literal 'NO_PLAN' instead. If the sentinel value ever changes, these two tests silently stop testing the sentinel case: the guard treats an unrecognized string as a real plan id, so the suppression assertions (spy['store'] == [], spy['reconcile'] == []) would then verify the wrong branch of the guard without failing.

Import the constant and use it here to keep the tests aligned with the authoritative sentinel definition.

As per path instructions, "Treat a hardcoded list that must mirror a set defined elsewhere ... as a defect unless it is derived from that source at build or run time. Name the authoritative definition and the drift it permits."

🔧 Proposed fix
+from marketplace_paths import NO_PLAN_SENTINEL
+
 class TestCmdRunCommonPlanIdGuards:
     ...
-    `@pytest.mark.parametrize`('plan_id', [None, '', 'NO_PLAN'])
+    `@pytest.mark.parametrize`('plan_id', [None, '', NO_PLAN_SENTINEL])
     def test_sentinel_suppresses_producer_side_finding_storage(self, spy, plan_id, capsys):
         ...
-    `@pytest.mark.parametrize`('plan_id', [None, '', 'NO_PLAN'])
+    `@pytest.mark.parametrize`('plan_id', [None, '', NO_PLAN_SENTINEL])
     def test_sentinel_suppresses_green_build_reconciliation(self, spy, plan_id, capsys):

Also applies to: 309-309

Source: Path instructions

Merged via the queue into main with commit b547758 Aug 2, 2026
11 checks passed
@cuioss-oliver
cuioss-oliver deleted the feature/mandatory-plan-id-build-results-ledger branch August 2, 2026 06:35
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant