* feat(#3970): per-task external-tracker content-resolution seam Implements ADR-3646 (Phase 1, #3970): a `<task tracker-id="...">` attribute plus a new optional `taskContentResolver` capability-manifest field let a capability resolve a task's action/verify/acceptance-criteria/read_first/done content from an external issue tracker instead of PLAN.md's inline body. - src/plan-document.cts: parses the `tracker-id` attribute into `PlanTask.trackerId` - src/task-content-resolution.cts: new leaf module — split/find/build/resolve, with a hard-halt (throw) contract on ambiguous/failed/timeout/malformed resolution, never a silent fallback to possibly-stale inline text - src/task-command-router.cts: new `task resolve-content --plan --task-id --raw` CLI verb wiring the module into a real process exit code - gsd-core/bin/lib/capability-validator.cjs: validates the new `taskContentResolver` manifest field (feature-role only, cross-capability trackerPrefix uniqueness) - gsd-core/workflows/execute-plan.md, gsd-core/references/loop-hook-dispatch.md, docs/reference/capability-manifest.md: wire the seam into the per-task loop and document it as a new `execute:task` point outside the existing contribution/step/gate vocabulary (unconditional in autonomous mode) Closes #3970 * fix(#3970): gate checkpoint tasks out of content resolution, close trackerPrefix grammar parity gap, cover path-traversal guard Standards/Spec code-review pass on the task-content-resolution seam (ADR-3646 Phase 1) found three defects: 1. execute-plan.md's task-content-resolution bullet fired on any tracker-id-bearing task with no check that it wasn't type="checkpoint:*", contradicting ADR-3646 Decision 1 (a checkpoint task must never enter resolve-content). plan-document.cts already parses trackerId: null unconditionally for checkpoint tasks; only the workflow prose needed the fix, so the bullet now explicitly excludes checkpoint tasks. 2. task-content-resolution.cts's parseResolverDeclaration accepted any non-empty trackerPrefix with no grammar check, while capability- validator.cjs's KEBAB_RE enforces kebab-case at install time — a Generative Fix Divergence gap. Added the same grammar (as a literal regex, documented as intentionally not shared across the .cts/.cjs build boundary) plus a parity test asserting the two surfaces agree across a valid/invalid trackerPrefix table. 3. task-command-router.cts's routeResolveContent path-traversal guard on --plan had zero test coverage. Added a test exercising a ../../../etc/passwit-shaped path and asserting the USAGE rejection names the offending path. * fix(#3970): sanitize resolver diagnostics and cap resolver timeoutMs Two findings caught by an isolated security-review pass on the task content resolution seam: - ResolverFailedError/ResolverMalformedOutputError embedded raw, unsanitized subprocess stderr/stdout (attacker/model-influenced via the tracker-id argv token) into .message. A hostile or buggy resolver could smuggle a newline plus a forged "Error: " line, or terminal escape sequences, into a diagnostic io.cjs's error() writes verbatim to stderr. Fixed at the constructor (task-content-resolution.cts) via io.cjs's existing formatDiagnosticToken(), so every caller of resolveTaskContent gets a safe .message by construction. - capability-validator.cjs's validateTaskContentResolverFields had no upper bound on taskContentResolver.invoke.timeoutMs, letting a manifest declare an effectively unbounded value and defeat the "bounded subprocess" design intent. Added a 120000ms ceiling specific to this field, without touching the shared isPositiveIntegerMs() helper (still used unbounded by the reviewer lane's timeoutFloorMs and probe timeoutMs). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * fix(#3970): fix gsd-test failures — stale prose allowlist line and stderr-vs-message assertion gsd-test (remote dockerized matrix) came back red with 5 failures on this PR; all five are real defects, fixed here. - tests/no-bare-gsd-tools-command-position.test.cjs: PROSE_ALLOWLIST's execute-plan.md entry pointed at line 415, which ffc190df4's checkpoint-exclusion caveat (added near line 221) shifted down by one line. The actual "validated downstream by gsd-tools uat classify-coverage" descriptive mention now sits at line 416. Updated the allowlist entry's line number to match. - tests/task-command-router-resolve-content.test.cjs: the path-traversal test asserted the outside-project-scope diagnostic against the thrown ExitError's own .message. io.cts's error() (ADR-3889) writes its human-readable message to fd 2 via writeAllSync and then throws a bare `new ExitError(1)` with no message argument — by design, so the exception carries no duplicate text and the thrown ExitError's message defaults to "process exit 1" (cli-exit.cts's ExitError constructor). Root cause was the test, not the source: task-command-router.cjs's outside-project-scope rejection already calls error() correctly and the diagnostic text is genuinely emitted, just on fd 2, not on the exception. Fixed the test to capture fd-2 writes (mirroring tests/estimate-calibrate.test.cjs's runCalibrateExpectError and this same file's own captureStdout for fd 1) and assert against the captured stderr text instead of err.message. This was masked locally because a manual `node -e` sanity check that only inspects the caught exception's .message cannot see what the real node:test run actually failed on. Emitted-Drift-Ack-Growth: execute-plan.md — adds the ADR-3646 task-content-resolution bullet and checkpoint-exclusion caveat to the per-task execute loop; a real behavioral prose addition, not incidental bloat. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * docs(#3970): backfill changeset PR number (pr:0 -> pr:4000) --------- Co-authored-by: sim <sim@local> Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -396,6 +396,11 @@ function validateCapability(cap, folderId) {
|
||||
errors.push(...validateRuntimeBody(cap));
|
||||
// A host that is ALSO a reviewer keeps exactly one manifest (ADR-2782 D1).
|
||||
errors.push(...validateReviewerBody(cap));
|
||||
// ADR-3646: a runtime capability installs a host CLI, it does not resolve
|
||||
// task content — taskContentResolver is feature-only.
|
||||
if (cap.taskContentResolver !== undefined) {
|
||||
errors.push('role:runtime capability must not have a "taskContentResolver" body (feature-only field)');
|
||||
}
|
||||
} else if (cap.role === 'reviewer') {
|
||||
// ADR-2782 D3 — a lane that is not an install target. No runtime body, no
|
||||
// install surface, no runtimeCompat (it surfaces through no host runtime).
|
||||
@@ -718,6 +723,118 @@ function validateFeatureBody(cap) {
|
||||
}
|
||||
}
|
||||
|
||||
// ADR-3646: optional per-task external-tracker content-resolution seam.
|
||||
errors.push(...validateTaskContentResolver(cap));
|
||||
|
||||
return errors;
|
||||
}
|
||||
|
||||
/**
|
||||
* ADR-3646 — validate an OPTIONAL `taskContentResolver` body on a `role:
|
||||
* "feature"` capability. Absence is never an error (most manifests won't
|
||||
* have one); presence is strictly validated.
|
||||
*
|
||||
* Wrapped in try/catch to degrade any unexpected throw (a hostile Proxy, a
|
||||
* throwing getter, etc.) to a single validation error rather than crashing
|
||||
* every consumer of loadRegistry, per the #1461 OVL-1 discipline that
|
||||
* `validateReviewerBody` follows.
|
||||
*
|
||||
* @param {object} cap The parsed capability manifest.
|
||||
* @returns {string[]} Array of error strings; empty = valid or absent.
|
||||
*/
|
||||
function validateTaskContentResolver(cap) {
|
||||
try {
|
||||
return validateTaskContentResolverFields(cap);
|
||||
} catch (err) {
|
||||
return ['capability taskContentResolver body could not be validated: ' + safeErrorMessage(err)];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Upper bound for `taskContentResolver.invoke.timeoutMs`, specific to this
|
||||
* field only. `isPositiveIntegerMs()` has no ceiling and stays that way — it
|
||||
* is shared with the reviewer lane's `timeoutFloorMs` and probe `timeoutMs`,
|
||||
* which may legitimately need a longer or unbounded value. Without a ceiling
|
||||
* here, a manifest could declare `Number.MAX_SAFE_INTEGER` and let
|
||||
* `resolve-content` hang near-indefinitely on a stuck/malicious resolver,
|
||||
* defeating the feature's "bounded subprocess" design intent.
|
||||
*/
|
||||
const TASK_CONTENT_RESOLVER_TIMEOUT_CEILING_MS = 120000;
|
||||
|
||||
function validateTaskContentResolverFields(cap) {
|
||||
const errors = [];
|
||||
if (typeof cap !== 'object' || cap === null || Array.isArray(cap)) return errors;
|
||||
|
||||
const tcr = cap.taskContentResolver;
|
||||
if (tcr === undefined) return errors; // optional — never an error to omit
|
||||
|
||||
const ctx = 'capability "' + (typeof cap.id === 'string' ? cap.id : '(unknown)') + '"';
|
||||
|
||||
if (typeof tcr !== 'object' || tcr === null || Array.isArray(tcr)) {
|
||||
const got = tcr === null ? 'null' : Array.isArray(tcr) ? 'array' : typeof tcr;
|
||||
errors.push(
|
||||
ctx + ' taskContentResolver must be an object (got: ' + got + '). ' +
|
||||
'Omit the key entirely to declare no resolver — an explicit null is not an omission.',
|
||||
);
|
||||
return errors; // cannot validate fields of a non-object
|
||||
}
|
||||
|
||||
// ── trackerPrefix — same grammar as a capability id ─────────────────────
|
||||
if (typeof tcr.trackerPrefix !== 'string' || tcr.trackerPrefix.length === 0 || !KEBAB_RE.test(tcr.trackerPrefix)) {
|
||||
errors.push(
|
||||
ctx + ' taskContentResolver.trackerPrefix must be a non-empty kebab-case string matching ' +
|
||||
String(KEBAB_RE) + ' (got: ' + describeValue(tcr.trackerPrefix) + ')',
|
||||
);
|
||||
}
|
||||
|
||||
// ── invoke ────────────────────────────────────────────────────────────
|
||||
const inv = tcr.invoke;
|
||||
if (typeof inv !== 'object' || inv === null || Array.isArray(inv)) {
|
||||
const got = inv === null ? 'null' : Array.isArray(inv) ? 'array' : typeof inv;
|
||||
errors.push(ctx + ' taskContentResolver.invoke must be an object (got: ' + got + ')');
|
||||
return errors; // cannot validate sub-fields of a non-object
|
||||
}
|
||||
|
||||
if (typeof inv.binary !== 'string' || inv.binary.length === 0) {
|
||||
errors.push(ctx + ' taskContentResolver.invoke.binary must be a non-empty string');
|
||||
}
|
||||
|
||||
if (!Array.isArray(inv.args)) {
|
||||
errors.push(ctx + ' taskContentResolver.invoke.args must be an array of strings');
|
||||
} else {
|
||||
let hasPlaceholder = false;
|
||||
for (const a of inv.args) {
|
||||
if (typeof a !== 'string') {
|
||||
errors.push(ctx + ' taskContentResolver.invoke.args entries must be strings (got: ' + describeValue(a) + ')');
|
||||
} else if (a === '{{id}}') {
|
||||
hasPlaceholder = true;
|
||||
}
|
||||
}
|
||||
if (!hasPlaceholder) {
|
||||
errors.push(
|
||||
ctx + ' taskContentResolver.invoke.args must contain a "{{id}}" placeholder — ' +
|
||||
'without it the resolved tracker id could never reach the resolver subprocess',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (!isPositiveIntegerMs(inv.timeoutMs)) {
|
||||
errors.push(
|
||||
ctx + ' taskContentResolver.invoke.timeoutMs must be a positive integer of milliseconds — ' +
|
||||
'an unbounded resolver call could hang task execution indefinitely ' +
|
||||
'(got: ' + describeValue(inv.timeoutMs) + ')',
|
||||
);
|
||||
} else if (inv.timeoutMs > TASK_CONTENT_RESOLVER_TIMEOUT_CEILING_MS) {
|
||||
// Ceiling specific to this field — `isPositiveIntegerMs()` itself stays
|
||||
// unbounded because it is shared with the reviewer lane's
|
||||
// `timeoutFloorMs`/probe `timeoutMs`, which have no such ceiling.
|
||||
errors.push(
|
||||
ctx + ' taskContentResolver.invoke.timeoutMs must not exceed ' +
|
||||
TASK_CONTENT_RESOLVER_TIMEOUT_CEILING_MS + 'ms — an unbounded-in-practice value defeats the ' +
|
||||
'"bounded subprocess" design intent (got: ' + inv.timeoutMs + ')',
|
||||
);
|
||||
}
|
||||
|
||||
return errors;
|
||||
}
|
||||
|
||||
@@ -943,7 +1060,7 @@ const HTTP_ONLY_INVOKE_FIELDS = ['hostConfigKey', 'defaultHost', 'path', 'model
|
||||
|
||||
// Feature-only fields are as forbidden on a lane-only capability as on a runtime
|
||||
// one; a `role: "reviewer"` capability owns no artefacts and wires no loop point.
|
||||
const FEATURE_FIELDS_FORBIDDEN_ON_REVIEWER = ['skills', 'agents', 'steps', 'contributions', 'gates', 'hooks', 'activationKey'];
|
||||
const FEATURE_FIELDS_FORBIDDEN_ON_REVIEWER = ['skills', 'agents', 'steps', 'contributions', 'gates', 'hooks', 'activationKey', 'taskContentResolver'];
|
||||
|
||||
// GATE A: installSurface → allowed hooksSurface values (DEFECT.GENERATIVE-FIX: parity invariant)
|
||||
// Derived from the actual pairings in the 16 real runtime descriptors.
|
||||
@@ -3184,6 +3301,11 @@ function validateCrossCapability(capMap, centralKeys, centralPatterns = []) {
|
||||
const laneSlugClaims = new Map(); // slug → capId[]
|
||||
const laneFlagClaims = new Map(); // flag → capId[]
|
||||
const laneSectionClaims = new Map(); // reviewsSection → capId[]
|
||||
// ADR-3646: task-content resolver tracker-prefix uniqueness across the
|
||||
// MERGED first-party ∪ overlay set, mirroring the reviewer-lane collision
|
||||
// pattern above — two resolvers claiming the same prefix would make
|
||||
// `execute:task` dispatch ambiguous (which capability's resolver runs?).
|
||||
const trackerPrefixClaims = new Map(); // trackerPrefix → capId[]
|
||||
|
||||
// Claims are ACCUMULATED and reported after the sweep, never reported on the
|
||||
// second claimant. Reporting pairwise-on-collision looks equivalent and is not:
|
||||
@@ -3204,6 +3326,15 @@ function validateCrossCapability(capMap, centralKeys, centralPatterns = []) {
|
||||
};
|
||||
|
||||
for (const [capId, cap] of capMap) {
|
||||
// ADR-3646: a MALFORMED taskContentResolver body was already reported by
|
||||
// validateCapability — do not double-report; only claim well-shaped
|
||||
// bodies. Independent of the reviewer-lane checks below, so it runs even
|
||||
// for capabilities that carry no `reviewer` body at all.
|
||||
const tcr = cap.taskContentResolver;
|
||||
if (typeof tcr === 'object' && tcr !== null && !Array.isArray(tcr)) {
|
||||
claim(trackerPrefixClaims, tcr.trackerPrefix, capId);
|
||||
}
|
||||
|
||||
const r = cap.reviewer;
|
||||
// A capability with no lane contributes to no uniqueness set. A MALFORMED
|
||||
// body was already reported by validateCapability — do not double-report.
|
||||
@@ -3225,6 +3356,7 @@ function validateCrossCapability(capMap, centralKeys, centralPatterns = []) {
|
||||
[laneSlugClaims, 'slug'],
|
||||
[laneFlagClaims, 'flag'],
|
||||
[laneSectionClaims, 'reviewsSection'],
|
||||
[trackerPrefixClaims, 'taskContentResolver.trackerPrefix'],
|
||||
]) {
|
||||
for (const [key, claimants] of claims) {
|
||||
if (claimants.length < 2) continue;
|
||||
@@ -3734,6 +3866,7 @@ module.exports = {
|
||||
validateCommandEntry,
|
||||
validateRuntimeCompat,
|
||||
validateFeatureBody,
|
||||
validateTaskContentResolver,
|
||||
validateConfigHome,
|
||||
validateArtifactKindEntry,
|
||||
validateArtifactLayout,
|
||||
|
||||
@@ -96,3 +96,25 @@ Honor `onError` if the check itself errors: `skip` means treat as non-blocking a
|
||||
|
||||
If `activeHooks` is absent, null, or an empty array, skip silently and continue to the next
|
||||
step in the workflow. No output to the user is needed.
|
||||
|
||||
## The `execute:task` point (a different shape)
|
||||
|
||||
`execute:task` exists below wave granularity — it is evaluated once per task, inside the
|
||||
`execute:wave:pre` / `execute:wave:post` bracket, immediately before that task's `read_first`
|
||||
gate. It is **not** one of the 12 points documented above, does not appear in `steps` /
|
||||
`contributions` / `gates`, and is never dispatched through `gsd_run loop render-hooks <point>` or
|
||||
this file's `activeHooks` envelope.
|
||||
|
||||
Instead, a capability declares task-content resolution directly in its manifest body via
|
||||
`taskContentResolver` (`trackerPrefix` + a bounded `invoke`) — see
|
||||
[Capability manifest reference](../../docs/reference/capability-manifest.md). `execute-plan.md`'s
|
||||
per-task loop calls `gsd_run task resolve-content --plan <path> --task-id <tracker-id> --raw`
|
||||
directly, an unconditional, required subprocess invocation with a real, binding exit code —
|
||||
never a prose-dispatched `step`/`gate` entry chosen from an `activeHooks` array.
|
||||
|
||||
This point always runs — there is no `when` config gate and no autonomous-mode elision. That is
|
||||
deliberate, not an oversight: the twelve points above are best-effort prose dispatch, which
|
||||
`execute:task`'s hard-halt safety property cannot be built on top of (a missed dispatch is
|
||||
indistinguishable from a legitimate resolver-empty fallback). See
|
||||
[ADR-3646](../../docs/adr/3646-per-task-content-resolution-seam.md) for the full rationale,
|
||||
including why a `kind: "gate"` shape was rejected outright.
|
||||
|
||||
@@ -218,11 +218,12 @@ Deviations are normal — handle via rules below.
|
||||
1. Read @context files from prompt
|
||||
2. **MCP tools:** If CLAUDE.md or project instructions reference MCP tools (e.g. jCodeMunch for code navigation), prefer them over Grep/Glob when available. Fall back to Grep/Glob if MCP tools are not accessible.
|
||||
3. Per task:
|
||||
- **MANDATORY read_first gate:** If the task has a `<read_first>` field, you MUST read every listed file BEFORE making any edits. This is not optional. Do not skip files because you "already know" what's in them — read them. The read_first files establish ground truth for the task.
|
||||
- **Task-content resolution:** If this task is NOT `type="checkpoint:*"` AND it carries a `tracker-id` attribute, run `gsd_run task resolve-content --plan "<plan path>" --task-id "<tracker-id>" --raw` BEFORE anything else for this task — before the read_first gate below, since read_first is itself a field this call can resolve. A `type="checkpoint:*"` task NEVER enters this resolution, regardless of whether it carries a `tracker-id` attribute — its interactive structure always stays sourced from PLAN.md (ADR-3646 Decision 1); see the `type="checkpoint:*"` bullet below. A **non-zero exit is a HARD HALT**: surface the tracker-id, the tracker prefix, and the command's stderr, and STOP — do NOT proceed to read this task's inline PLAN.md content as a fallback (that defeats the point of the seam: see ADR-3646). On exit 0 with `resolved: true`, the returned `content` object SUPERSEDES this task's `<read_first>`/`<action>`/`<verify>`/`<acceptance_criteria>`/`<done>` for every remaining bullet in this step — every gate below reads from the resolved content instead of PLAN.md. On exit 0 with `resolved: false` (for any `reason`), proceed exactly as today and read the task's inline PLAN.md content. A task with no `tracker-id` attribute, or a checkpoint task, is unaffected — this step is unconditional but resolves to a no-op instantly for the common case.
|
||||
- **MANDATORY read_first gate:** If the task has a `<read_first>` field (or the resolved content carries one), you MUST read every listed file BEFORE making any edits. This is not optional. Do not skip files because you "already know" what's in them — read them. The read_first files establish ground truth for the task.
|
||||
- `type="auto"`: if `tdd="true"` → TDD execution. Implement with deviation rules + auth gates. Verify done criteria. Commit (see task_commit). Track hash for Summary.
|
||||
- `type="tracer"`: execute like `type="auto"` (production-quality, real `<verify>`, commit), then run the tracer feedback gate BEFORE any expansion task — an early integration checkpoint. Evaluate in order (#3299). First, `gate="blocking-human"` → STOP → return a `checkpoint:human-verify` via checkpoint_protocol — every mode, auto included (golden rule 6, checkpoints.md). Next, Auto mode active (`AUTO_CHAIN` or `AUTO_CFG`): re-run the tracer `<verify>`; on failure HALT and surface (deviation) — do NOT start expansion tasks. Next, `HUMAN_VERIFY_MODE` is `end-of-phase` (default) AND the tracer's `<verify>` carries only `<automated>` (no `<human-check>`) → re-run the tracer `<verify>`; on failure HALT and surface as a deviation exactly as in the auto-mode branch — never a checkpoint; on success log `⚡ Tracer verified end-to-end — expanding` and continue to expansion, do NOT synthesize a checkpoint. Otherwise (`mid-flight`, or the tracer carries genuine human-observable evidence) → STOP → return a `checkpoint:human-verify` for the tracer via checkpoint_protocol before expansion.
|
||||
- `type="checkpoint:*"`: STOP → checkpoint_protocol → wait for user → continue only after confirmation.
|
||||
- **HARD GATE — acceptance_criteria verification:** After completing each task, if it has `<acceptance_criteria>`, you MUST run a verification loop before proceeding:
|
||||
- **HARD GATE — acceptance_criteria verification:** After completing each task, if it has `<acceptance_criteria>` (inline or resolved), you MUST run a verification loop before proceeding:
|
||||
1. For each criterion: execute the grep, file check, or CLI command that proves it passes
|
||||
2. Log each result as PASS or FAIL with the command output
|
||||
3. If ANY criterion fails: fix the implementation immediately, then re-run ALL criteria
|
||||
|
||||
Reference in New Issue
Block a user