Repository navigation
feat(agents): shared AX target and result contract with a read-only inspector - #346
Merged
Merged
Conversation
…nspector Add a provisional target/result decision record and a read-only target inspector with CLI and MCP parity (HACK-1144, WU31). - `hack project target [--json]` resolves the exact project, checkout, runtime instance, optional service, env overlay selection and a Hack config generation from local files only. It performs no registry write, engine call or Git mutation, and reports runtime as not probed. - `hack.project.target` (read-only MCP tool) spawns the same command and passes selection through unresolved, so every resolution refusal is the CLI's. Its data is the CLI envelope re-validated against the contract. - Refusals are a closed, value-free catalogue (reason, stage, retry) that reuses existing error codes. `--expect-generation` refuses stale config; a config change during the read is refused instead of snapshotted. - Extract the Compose project name resolver from commands/project.ts so the inspector and commands share one implementation. - Paired CLI/MCP fixtures: two projects, a linked-worktree branch and an explicit instance, conflicting selectors, stale generation, ambiguous equal names, a moved checkout, detached worktree and no-write checks. Names, fields and reasons are provisional pending compatibility review. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…n gaps The read-only target inspector now refuses a Compose file that is not YAML, whose top level is not a mapping, or whose services key is not a mapping, as compose_parse_failed, instead of reporting a target with no services. Paired CLI/MCP fixtures now cover env selection (project default, explicit overlay, base), explicit instance slugging (Feature/X -> <base>--feature-x), two registrations owning one checkout, config_parse_failed, compose_parse_failed and generation_input_too_large. A stubbed CLI proves the MCP tool re-validates the envelope, and the validator's code/message consistency check has direct tests. Restores the buildEnvExplainArgs JSDoc. Refs HACK-1144 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This comment has been minimized.
This comment has been minimized.
The new read-only target inspector accepts --env and --service, so the env matrix source check requires a row for it. It reports only the overlay selection and never reads env files or values. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A filesystem fault while reading target inputs (a mode-000 or directory Compose file) escaped as E_UNEXPECTED with the raw OS message and absolute path. Map errno errors to the closed input_permission_denied and input_unreadable refusals, and let the adapter validator pass a detail-less error only for parser E_USAGE refusals. Strengthen the no-registry-write proof by comparing against the registry bytes written in beforeAll, and cover the validator's authority and runtime.observed checks. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
roodboi
marked this pull request as ready for review
October 20, 2026 17:59
roodboi
added a commit
that referenced
this pull request
Oct 21, 2026
Brings #355 (G4, HACK-1272: retire authored starts refused before journal reservation), #352, #341, #342 and #346 into the G1 closed-refusal branch. Both sides' behavior is kept. Conflict resolutions: - docs/reference/native-authored-commands.md: keep G1's explanation of `pending` (in-progress start or failed start with retained evidence; only the owner's cleanup proof may retire it) and append G4's pointer that `down --recover` retires an attempt that never reserved runtime state when no startup is running. - src/lib/native-authored-command.ts: the `pending` status note keeps G1's wording and adds G4's `hack down --recover` hint. Auto-merged code keeps G4's canceled `unreserved` exit 130 ahead of G1's stopped-runtime classification. Semantic fix: G1's stoppedRuntimeStartup message map had no entry for G4's new `unreserved` outcome, so a runtime_not_running start that was retired as unreserved would have produced an undefined message. It now reports E_DOCKER_UNAVAILABLE with an `unreserved` message (intent retired, `hack ps` reports not_started). G4's `down --recover` pending-start path already refuses through commandFailure as a closed coded HackCliError (E_LIFECYCLE_FAILED, values omitted), and unreserved startup failures map to E_STARTUP_INCOMPLETE or E_DOCKER_UNAVAILABLE, so no stack or help is printed. - tests/native-authored-project-start.test.ts: the fixture keeps G1's `stoppedDuringServe` and G4's `failBeforeReceiptCode` and `unreservedInspection` options. The stub serve emits G4's configurable failBeforeReceipt code and G1's wrapped stopped-runtime refusal. The stub inspect answers both failure modes, with `native_graph_unreserved` only when requested. The duplicate native-runtime-client import is merged into one. Semantic fix: G4's HACK-1272 call-log expectation now includes G1's read-only `runtime status --json` preflight between plan and serve. Both sides' tests are kept. - tests/native-authored-command.test.ts (auto-merged): add an `unreserved` case to the stopped-runtime startup table. Verification (Bun 1.4.2, macOS): - packages/cli typecheck and check: 0 errors. - native-authored-command, native-refusal-cli, native-authored-command-dispatch and cli-spawn-env-isolation: 124 pass. - Other native-authored-* and native-runtime-* suites: 409 pass, 5 fail. 2 are load-timing failures that pass when rerun alone. 3 are host live-session census failures in the recovery and incarnation suites, which fail identically on origin/next and ead8383 on this host. - native-authored-project-start (--timeout 60000): 48 pass. The 3 host-metadata cases are intermittent (HACK-1299). They also fail intermittently on origin/next and ead8383 and pass when run alone. - Six scripts/check-native-*-plan-cli.ts: all pass after build and build:config-compiler. - docs:cli-reference and generate:agent-plugins: no drift. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Linear: HACK-1144 (WU31, shared AX target and result contracts).
This adds a provisional target/result decision record and a read-only target inspector that behaves the same through the CLI and MCP.
hack project target [--project=…|--path=…] [--branch=…] [--service=…] [--env=…] [--expect-generation=…] [--json]resolves the exact project, checkout (primary, linked or non-Git), runtime instance (explicit, worktree-derived or default, plus its Compose project name), optional service, env overlay selection and a Hack config generation. It reads local files only. It does not write the registry (not even the last-seen touch), call the engine or Docker, start or repair anything, or change Git. It reportsruntime: { observed: false, reason: "not_probed" }andauthority: "none".hack.project.target(read-only) spawns the same command and passesprojectName/paththrough without resolving them first. Every resolution refusal therefore comes from the CLI.datais the CLI envelope, re-validated against the contract: off-contract output becomesnulland foreign fields are dropped.stdoutis withheld becausedatacarries the same envelope.stageand aretrydisposition, and reuses an existingHACK_ERROR_CODEScode, so no new code or guidance render is needed.--expect-generationrefuses stale config withobserved_generation. If the config changes during the read, the inspector refuses instead of returning a mixed snapshot.servicesis not a mapping is refused ascompose_parse_failed(E_CONFIG_PARSE, stageproject, retryfix_project). It is no longer reported as a target with no services, and--serviceno longer turns it intoservice_not_found. An absent Compose file or an emptyservices:key still givesservices: [].--branchis slugged likehack up --branch, soFeature/Xgives<base>--feature-x..hack/hack.config.json,hack.tomlanddocker-compose.ymlonly. Ordinary source edits do not change it.ambiguous_project, and so is a checkout that two registrations own.src/commands/project.tstosrc/lib/compose-project-name.ts, so the inspector and the commands share one implementation. Behavior is unchanged.docs/reference/ax-target-result-contract.md(decision record, schema, refusal table, compatibility matrix, known gaps), ahack.project.targetsection and row indocs/reference/mcp-tools.md, and a regenerateddocs/reference/cli.md.All names, fields, reasons and retry values are marked provisional for the compatibility review.
Rework after verification (head
bc49c11a)compose_parse_failedrefusal and documented it, together with a "Compose validity" known gap: the inspector does not rundocker compose config, so a file that parses but that Compose would reject is still reported.base), explicit instance slugging, two registrations owning one checkout,config_parse_failed,compose_parse_failed(with and without--service, and a list-valuedservices) andgeneration_input_too_large.HACK_MCP_COMMANDprints a stored envelope. It proves the tool re-validates the payload instead of passing it on. Added direct validator tests for the code/message consistency check.buildEnvExplainArgsJSDoc back above its function insrc/mcp/server.ts.Validation
bun test tests/project-target-inspect.test.ts: 18 pass. Each paired case runs the source CLI directly and through the MCP tool, whose child is the same source CLI viaHACK_MCP_COMMAND. Both useprivateCliEnv, a private HOME/HACK_HOME/registry and an isolated Docker client config. The envelopes must be equal apart fromobserved_at. Cases:alpha--feature-login), an explicit instance overriding it with the Git branch unchanged, andFeature/Xslugged toalpha--feature-xbaseinvalid_selector, MCPinvalid_argumentswith no spawn.hackcontents and the Git worktree list are unchangednull, foreign fields are dropped, and a mismatched message becomesnulltests/integration-ownership.test.ts,tests/cli-spawn-env-isolation.test.ts,tests/mcp-inspect-tools.test.ts,tests/agent-instruction-source.test.ts,tests/cli-help.test.tsandtests/experimental-gating.test.ts(78 pass together with the focused file).tests/mcp.test.ts: 11 pass.bun run --cwd packages/cli typecheck: clean.bun run --cwd packages/cli check: 0 errors. The 34 warnings it reports are in existing files.ultracite checkon the changed files reports nothing.bun run docs:cli-reference: no drift (the CLI surface did not change in the rework).bun run privacy:check: ok. No Rust changed.Negative controls (mutation proofs)
Each mutation was applied alone and then reverted. The focused suite failed every time.
First round:
--project/--pathconflict checkpathwhenprojectNameis set (pre-resolution)Rework (all 12 killed):
resolveEnvSelectionreturns base for an explicit overlayresolveEnvSelectionignores the project default--envargv entry frombuildTargetInspectArgssanitizeBranchSlugcandidates.length > 1)config_parse_failedrefusalparseJson(stdout)without re-validationservicesreturns no servicesRemaining gaps (host-bound or out of scope)
docker compose configis not run.hack.project.jsonprojects are refused asproject_inputs_unsupported.--project/--envgrammar refusals stayE_USAGEwith nodetail.reason, as for every command.Rework 2
Fixes the three open verifier findings from round 1.
Registry no-write proof (should-fix).
inspection writes nothingnow comparesprojects.jsonagainst the bytes and mtime thatbeforeAllwrote, not a baseline read inside the test after earlier paired cases had already run the CLI. Mutant: injecttouchProjectRegistration({ project: context })afterselectProjectinresolveTarget. Round 1: survived. Now: killed (registry text differs).Unreadable inputs leaked OS text and paths (blocking). A filesystem fault while reading inputs used to escape
inspectTargetasE_UNEXPECTEDwith the raw message, for exampleEACCES: permission denied, open '<abs path>/.hack/docker-compose.yml'. It is now one of two closed, value-free refusals:input_permission_denied(E_PERMISSION,project,fix_project) forEACCES/EPERMinput_unreadable(E_CONFIG_INVALID,project,fix_project) for any other errno error. New repro found in this round: when.hack/docker-compose.ymlis a directory, the generation read treats it as absent, and the Compose read then threwEISDIR.The adapter validator now accepts a detail-less error only when its code is
E_USAGE(the parser refusals). Any other detail-less error, such as anE_UNEXPECTEDraised by a defect, becomesdata: null. The contract doc andmcp-tools.mdnow say so.Validator data checks untested (minor). New tests mutate a real inspector success envelope:
authorityset to something other than"none"or removed,runtime.observed: true, a wrongruntime.reason, andruntime: null. The authority case is also checked through the stubbed MCP CLI.New paired CLI/MCP fixtures:
dir-compose, which is deterministic and works as root, andlocked-compose(mode 000; skipped when running as root, because root can read the file). Both also assert that the CLI envelope contains neither the fixture root nor the errno name.Mutations, all killed by the named test:
touchProjectRegistrationafter project selectionEACCEStoinput_unreadableE_USAGEonly, drifted CLI outputauthority === "none"runtime.observed === falseGates run locally on head
5352415d:bun run typecheckandbun run checkpass, as dobun run --cwd packages/cli typecheckandcheck.origin/next..HEADfinds no leaks.mcpandenv-command-matrixsuites pass: 37 pass, 0 fail.bun run test(7369 tests, run on a host under load average 17–20) had 28 failures in 18 files. Serial reruns with--timeout 60000passed in 10 of those files. The other 8 files are host-native tests (native-authored-*,native-compose-adoption-*,native-compose-proxy-probe,native-compose-retained-storage-command,native-compose-storage-activation-command), and they keep failing serially. The same native failures reproduce on the unmodified basea3eb34e9(checked: proxy-probe, retained-storage-command, process-incarnation). They are pre-existing host-environment failures, not caused by this change.Still open:
E_UNEXPECTEDwith free text. MCP returnsdata: nullfor it. Mapping every thrown value to a closed refusal would hide defects, so it was not done.gitchild, if one were thrown, would also be reported asinput_unreadable.Rework 2: independent re-check (head
5352415d, no new commits)A second rework pass started from the current remote head,
5352415d. All three round-1 findings are already fixed there, so this pass pushes nothing.bun test tests/project-target-inspect.test.ts: 22 pass, 0 fail. This includes the permission-denied case, which runs because the host user is not root.touchProjectRegistrationafter project selection: "inspection writes nothing"EACCESmapped toinput_unreadable: permission-denied inputE_USAGEonly, drifted CLI outputauthority === "none"removed: authority/readiness, drifted CLI outputruntime.observed === falseremoved: authority/readinessruntime.reason === "not_probed"removed: authority/readinessbuildTargetInspectArgs) does not hide a dropped flag. Removing any one of--project,--path,--branch,--service,--envor--expect-generationfrom the helper fails 1 to 8 paired tests through their per-field assertions.5352415d: all 16 checks pass, includingtest,secret-scanand everydocker-e2eshard.next(099877a0). It was not updated, because force-pushing and mergingnextwere both outside this pass.Open (not fixed; each needs a product decision):
projects.jsonis present but unreadable (mode 000) or is not valid JSON, the shared lenient reader returns an empty registry.--paththen reportsregistration: null, and--projectrefuses withproject_not_found. Lifecycle commands read the registry the same way, so the answer matches what they would do. The caller just cannot tell this case from a project that is genuinely unregistered. The options are a closed refusal, an explicitregistrationstate, or keeping parity with the commands.EMFILE,EAGAIN,EIO,EINTR) map toinput_unreadable, whose retry isfix_project. For these errors,reinspectis the more accurate advice.Rework 2: re-confirmation on merged head
8395b342(no new commits)The branch now includes
next(b431f0e7, merged by the main session). That merge does not touch the inspector, its tests or its docs.project-target-inspect,env-command-matrix,mcp,mcp-inspect-tools,cli-spawn-env-isolation,integration-ownership): 69 pass, 0 fail.packages/clitypecheck and check: 0 errors.docs:cli-reference: no drift. gitleaks 8.30.1 over theorigin/next..HEADpatches: no leaks.5352415d, whose inspector files are identical on8395b342(registry touch, detail-less pass-through for any code, authority check removed,runtime.observedcheck removed, filesystem-fault mapping removed,EACCESmapped toinput_unreadable). Each one was killed by the named test.🤖 Generated with Claude Code