Seven findings from the two-axis review, all fixed in place. THE ONE THAT MATTERS: cmdTodoComplete validated sourcePath and targetPath and then ran every fs call against the RAW strings — existsSync, statSync, readFileSync, platformWriteSync, unlinkSync, and the dry-run path payload — never sourceCheck.resolved / targetCheck.resolved. That is the exact "validate one path, use another" shape ADR-4650 names as the defect this epic exists to prevent, and it is the same bug this phase had just fixed in check-command-router. Committed inside the fix for it. All I/O now uses the resolved paths; user-facing messages still echo the raw filename, never a resolved absolute path. A VACUOUS TEST, and the false doc claim it was propping up. The test "[RED #4327] an absolute path outside the project is rejected" would have passed with ZERO containment logic: path.join(pendingDir, '/abs/outside/x') yields <pendingDir>/abs/outside/x — Node does not let a later absolute segment escape — so the name is FOLDED under the root, passes containment, and simply 404s. The test only ever observed "Todo not found". It now asserts what is actually true and actually valuable: an absolute name is neutralized, and the real outside file is not read, not moved, and still present afterward. docs/CLI-TOOLS.md claimed such a path "is rejected as a usage error", which was false; it now describes the fold-under-root behavior. Traversal and embedded separators ARE rejected, and those claims stand. DUPLICATION THIS EPIC EXISTS TO REMOVE. resolvePath already did isAbsolute-or-join + validatePath + reject; cmdGapAnalysisPlanPost and cmdCheckPredicate each re-inlined the identical triplet in the same file. Both now call resolvePath. Cost, stated rather than hidden: its generic message replaces the two sites' distinct "phase-dir escapes…" wording. The message still names the offending input, and one predicate with one message is the point. SYMLINK COVERAGE was required by #4652's "Done when" and was missing. Added for both the todos root and --phase-dir, skipping cleanly on EPERM so the Windows lanes do not fail where unprivileged symlink creation is disallowed. Both fast-check properties were UNSEEDED. Seeded now. The changeset named "check decision-coverage-plan" as a boundary; that is a caller of the shared resolvePath, which the body never mentioned. Corrected. DISCLOSED, not hidden: ctx.phaseDir is now always the resolved ABSOLUTE path, so ${PHASE_DIR} interpolation and the "not found in <targetDir>" message show an absolute value where a relative --phase-dir previously produced a relative one. That is an observable output change. A test pins it and docs/reference/gate-predicates.md states it. Also regenerated scripts/lib/platform-conformance-tier.generated.cjs and its macos twin — the new tests changed check-predicate.test.cjs's tier classification. Caught by npm run lint:ci locally rather than by a bench run. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
6.6 KiB
Gate predicates (reference)
Diátaxis quadrant: Reference. This is the canonical specification of the capability gate
check.predicateevaluation path. For a step-by-step authoring guide, see How-to: add a command-exit-zero gate.
A capability gate's check block carries exactly one of three shapes
(query, predicate, agentVerdict), enforced by the registry validator
(capability-validator.cjs:validateGate). This page documents the
predicate shape and the kinds the built-in evaluator recognises.
Declaration
"gates": [
{
"point": "<loop-point>",
"check": {
"predicate": {
"kind": "<kind>",
"<kind-specific fields>"
}
},
"when": "<config-key>",
"blocking": true,
"onError": "halt"
}
]
The gate envelope (point, when, blocking, onError) follows the standard
contract documented in ADR-0894 (capability declaration format) and the
Loop Host Contract glossary entry in CONTEXT.md. This page covers only
check.predicate.
Evaluation path
- The loop-resolver (
gsd-tools loop render-hooks <point>) renders the active gate hook (including itscheck.predicatedeclaration) to the workflow. - The workflow gate-dispatch reads the hook in-context and, when the
checkshape ispredicate, runs:gsd_run check predicate --predicate '<predicate JSON>' [--phase-dir …] [--phase-number …] [--phase-req-ids …] --raw check-command-router.cts:cmdCheckPredicateparses the predicate, builds the production subprocess binding, and callsgate-predicate-evaluator.cjs:evaluatePredicate, which dispatches bypredicate.kind.- The evaluator returns the standard gate envelope:
{ "block": <bool>, "message": "<string>", "details": { … } } - The workflow applies the two-step gate contract unchanged:
- Step 1 — if the check command itself failed (non-zero exit, e.g. a
malformed predicate / unknown kind), route per
onError(haltorskip). - Step 2 — if the command succeeded, a
blocking: truegate halts onblock: true; an advisory gate showsmessageand continues.
- Step 1 — if the check command itself failed (non-zero exit, e.g. a
malformed predicate / unknown kind), route per
Built-in kinds
command-exit-zero
Runs a declared command in a bounded sh -c subprocess; exit 0 → pass,
non-zero → block, timeout → block. See ADR-2008 for the full sandbox
contract.
| Field | Type | Required | Default | Notes |
|---|---|---|---|---|
kind |
string | yes | — | Must be "command-exit-zero" |
command |
string | yes | — | The shell command. Non-empty, ≤ 4096 chars |
timeout |
number | no | 30 |
Positive finite number, seconds |
Interpolation. Before execution, three placeholders are substituted from
the gate context; all others are left untouched for sh to interpret:
| Placeholder | Source | Workflow flag |
|---|---|---|
${PHASE_NUMBER} |
the active phase number | --phase-number |
${PHASE_DIR} |
the active phase directory | --phase-dir |
${PHASE_REQ_IDS} |
the phase's requirement ids | --phase-req-ids |
An undefined placeholder interpolates to the empty string.
--phase-dir is confined to the project. The value is validated to resolve
inside the project root before any predicate is evaluated; one that escapes is
rejected as a usage error rather than evaluated. This applies to both kinds —
artifact-frontmatter-equals resolves its artifact under that directory, and
command-exit-zero interpolates it into ${PHASE_DIR} — so an unconfined value
would let a blocking gate return block: false on evidence from a directory
the caller chose (#4354). An absolute path inside the project is still accepted;
absolute is not a synonym for escaping. ${PHASE_DIR} always interpolates the
resolved absolute path, even when --phase-dir was given as a relative
value — a command relying on ${PHASE_DIR} staying relative must not assume
that.
Sandbox. cwd = project root; env = inherited from the GSD process; killed (SIGTERM) on timeout. The command runs as the user, on the user's machine — there is no sandbox boundary vs. the user's own shell. See ADR-2008 "Trust model".
Result mapping.
| Command outcome | block |
message |
|---|---|---|
| exit 0 | false |
command exited 0 |
| exit N (non-zero) | true |
command exited N: <stderr/stdout tail, ≤2000 chars> |
| timeout (SIGTERM) | true |
command timed out after <s>s: <tail> |
sh missing (ENOENT, exit 127) |
true |
command exited 127: sh: not found |
Validation errors (throw → check-command failure → Step-1 / onError).
- Missing, non-string, empty, or whitespace-only
command. commandlonger than 4096 chars.timeoutpresent but not a positive finite number.- Unknown
kind.
artifact-frontmatter-equals
Reads a Markdown file with YAML frontmatter from the current phase directory (or falls back to the project root for project-level artifacts) and compares a field's value to the declared expectation. The value is matched using loosely typed string comparison or exact matching, where numeric expectations will safely match stringified numeric frontmatter values.
| Field | Type | Required | Default | Notes |
|---|---|---|---|---|
kind |
string | yes | — | Must be "artifact-frontmatter-equals" |
artifact |
string | yes | — | Suffix or exact filename (e.g. WINDOWS.md) |
field |
string | yes | — | Frontmatter key to read |
equals |
any | yes | — | Expected value (compared with string coercion) |
Result mapping.
| Command outcome | block |
message |
|---|---|---|
Value matches equals |
false |
Frontmatter field "<field>" matches expected value (<expected>) |
| Value mismatch | true |
Frontmatter field "<field>" in <artifact> is <actual>, expected <expected> |
| Artifact file not found | true |
Artifact matching <artifact> not found in <targetDir> |
Validation errors (throw → check-command failure → Step-1 / onError).
- Missing or empty
artifactstring. - Missing or empty
fieldstring. - Missing
equalsvalue. - File read or YAML parsing failure (I/O errors).
Extensibility
The evaluator dispatches through a KIND_TABLE. Adding a new built-in kind is
a one-line registration in gate-predicate-evaluator.cts — no workflow changes
required, since the workflow dispatches any check.predicate to the same
gsd_run check predicate subcommand.
Related
- ADR-2008 — full decision record.
- How-to: add a command-exit-zero gate.
- ADR-0894 — capability declaration format.
src/gate-predicate-evaluator.cts,src/check-command-router.cts.