{
  "$schema": "http://json-schema.org/draft-07/schema#",
  "title": "MORPH-SPEC feature.json",
  "description": "Canonical shape of the authoritative, committed per-feature state at .morph/features/{feature}/feature.json (v5 per-feature state model). Born via `morph-spec create` (explicit) or the state-sync hook (implicit fallback). Server-side writers: `morph-spec approve/advance` (approvalGates), `morph-spec score` (taskScores), `morph-spec gate-decision` (gateDecisions), `morph-spec archive`/`finish` (status/archivedAt — `finish` archives internally before integrating). Never hand-edit the CLI-owned fields — manual Edits collide with the state-sync hook. This schema is documentational: additionalProperties stays true so older/newer CLIs interoperate.",
  "type": "object",
  "required": ["status", "approvalGates"],
  "properties": {
    "name": {
      "type": "string",
      "description": "Human-readable feature name, stamped at birth by `morph-spec create` (defaults to the slug) and by the state-sync hook's implicit registration. Kept out of the schema's `required` array — adding it there would invalidate every feature.json that predates identity stamping; instead `create` enforces it at birth and `morph-spec doctor`/`list` flag its absence leniently."
    },
    "description": {
      "type": "string",
      "description": "What/why of the feature. The one identity field a human must supply — `morph-spec create` refuses a new feature without it (--description, or --request which seeds it). Absent on features born via the implicit hook path; surfaced as a warning by `doctor`/`list`, never a hard read-time rejection (see name)."
    },
    "status": {
      "type": "string",
      "enum": ["draft", "in_progress", "completed", "abandoned"],
      "description": "Feature lifecycle status. `completed` is stamped by `morph-spec archive` together with archivedAt. `abandoned` is stamped by `morph-spec delete --archive` together with abandonedAt (dead scaffold / killed draft, kept in .morph/archive)."
    },
    "createdAt": { "type": "string", "format": "date-time" },
    "updatedAt": {
      "type": "string",
      "format": "date-time",
      "description": "Stamped server-side on every CLI/hook write."
    },
    "archivedAt": {
      "type": "string",
      "format": "date-time",
      "description": "Stamped by `morph-spec archive`."
    },
    "abandonedAt": {
      "type": "string",
      "format": "date-time",
      "description": "Stamped by `morph-spec delete --archive` when a dead/abandoned feature is moved to .morph/archive."
    },
    "approvalGates": {
      "type": "object",
      "description": "One entry per gate. Written ONLY by `morph-spec approve`/`advance` (approvedBy: \"auto\") or the state-sync hook on a human \"aprovado\" answer (approvedBy: \"manual\").",
      "properties": {
        "proposal": { "$ref": "#/definitions/gate" },
        "uiux": { "$ref": "#/definitions/gate" },
        "plan": { "$ref": "#/definitions/gate" },
        "review": { "$ref": "#/definitions/gate" }
      },
      "additionalProperties": false
    },
    "gateDecisions": {
      "type": "object",
      "description": "Trust decision persisted BEFORE acting on a gate, written ONLY by `morph-spec gate-decision` (decidedAt is server-side). Read by the gate-guard hook to verify the observed action matched the recorded decision.",
      "propertyNames": { "enum": ["proposal", "uiux", "plan", "review"] },
      "additionalProperties": {
        "type": "object",
        "required": ["pause", "reason", "signals", "trust", "decidedAt"],
        "properties": {
          "pause": { "type": "boolean", "description": "true = the gate must pause for a human (AskUserQuestion); false = auto-pass with a short summary." },
          "reason": { "type": "string", "description": "The signal that drove the decision (or 'nenhum sinal de risco')." },
          "signals": {
            "type": "object",
            "description": "Risk signals evaluated (free-form): taskCount, evalScore, standardsViolations, designSystemDivergence..."
          },
          "trust": { "type": "string", "enum": ["auto", "manual"] },
          "decidedAt": { "type": "string", "format": "date-time" }
        },
        "additionalProperties": false
      }
    },
    "taskScores": {
      "type": "object",
      "description": "Per-task eval scores, written ONLY by `morph-spec score` (updatedAt is server-side; attempts auto-increment; bestScore is kept).",
      "propertyNames": { "pattern": "^T\\d+$" },
      "additionalProperties": {
        "type": "object",
        "required": ["lastScore", "bestScore", "attempts", "updatedAt"],
        "properties": {
          "lastScore": { "type": "number", "minimum": 0, "maximum": 10 },
          "bestScore": { "type": "number", "minimum": 0, "maximum": 10 },
          "attempts": { "type": "integer", "minimum": 1 },
          "dimensions": {
            "type": "object",
            "properties": {
              "architecture": { "type": "number", "minimum": 0, "maximum": 10 },
              "contracts": { "type": "number", "minimum": 0, "maximum": 10 },
              "codeQuality": { "type": "number", "minimum": 0, "maximum": 10 },
              "testCoverage": { "type": "number", "minimum": 0, "maximum": 10 }
            },
            "additionalProperties": false
          },
          "updatedAt": { "type": "string", "format": "date-time" }
        },
        "additionalProperties": false
      }
    },
    "taskVerifications": {
      "type": "object",
      "description": "Deterministic verify results per task, written ONLY by `morph-spec verify` (task scope). updatedAt is server-side; attempts auto-increment. Persisted only from the primary root or the feature's own registered worktree — see verify.js decidePersistence().",
      "propertyNames": { "pattern": "^T\\d+$" },
      "additionalProperties": {
        "type": "object",
        "required": ["status", "nodes", "attempts", "updatedAt"],
        "properties": {
          "status": { "type": "string", "enum": ["pass", "fail"] },
          "nodes": {
            "type": "object",
            "required": ["build", "tests", "validators"],
            "properties": {
              "build": { "type": "string", "enum": ["pass", "fail", "skip"] },
              "tests": { "type": "string", "enum": ["pass", "fail", "skip"] },
              "validators": { "type": "string", "enum": ["pass", "fail", "skip"] },
              "e2e": { "type": "string", "enum": ["pass", "fail", "skip"], "description": "Docker bring-up + Playwright specs against the running stack. Absent on entries written before the node existed — which is why it is NOT in `required`." },
              "evals": { "type": ["string", "null"], "enum": ["pass", "fail", "skip", null], "description": "Eval suites, pulled out of the tests rollup. Written by the SAME function that writes the singular stamp (nodeStatuses in verify.js): the two shapes must stay identical, or a stamp would omit what the history records. null means the project declares no eval node at all — distinct from skip, which means it declares one and this run did not execute it. Absent on entries written before the field existed, which is why it is NOT in required." }
            },
            "additionalProperties": false
          },
          "score": { "type": ["number", "null"], "minimum": 0, "maximum": 10 },
          "attempts": { "type": "integer", "minimum": 1 },
          "updatedAt": { "type": "string", "format": "date-time" }
        },
        "additionalProperties": false
      }
    },
    "verifications": {
      "type": "object",
      "description": "Deterministic verify results at feature scope, written ONLY by `morph-spec verify` (feature scope, no task id).",
      "properties": {
        "feature": {
          "type": "object",
          "required": ["status", "nodes", "attempts", "updatedAt"],
          "properties": {
            "status": { "type": "string", "enum": ["pass", "fail"] },
            "nodes": {
              "type": "object",
              "required": ["build", "tests", "validators"],
              "properties": {
                "build": { "type": "string", "enum": ["pass", "fail", "skip"] },
                "tests": { "type": "string", "enum": ["pass", "fail", "skip"] },
                "validators": { "type": "string", "enum": ["pass", "fail", "skip"] },
                "e2e": { "type": "string", "enum": ["pass", "fail", "skip"], "description": "Docker bring-up + Playwright specs against the running stack. Absent on entries written before the node existed — which is why it is NOT in `required`." },
              "evals": { "type": ["string", "null"], "enum": ["pass", "fail", "skip", null], "description": "Eval suites, pulled out of the tests rollup. Written by the SAME function that writes the singular stamp (nodeStatuses in verify.js): the two shapes must stay identical, or a stamp would omit what the history records. null means the project declares no eval node at all — distinct from skip, which means it declares one and this run did not execute it. Absent on entries written before the field existed, which is why it is NOT in required." }
              },
              "additionalProperties": false
            },
            "score": { "type": ["number", "null"], "minimum": 0, "maximum": 10 },
            "attempts": { "type": "integer", "minimum": 1 },
            "updatedAt": { "type": "string", "format": "date-time" }
          },
          "additionalProperties": false
        }
      },
      "additionalProperties": false
    },
    "verification": {
      "type": "object",
      "description": "The single latest deterministic verify stamp (any scope), written ONLY by `morph-spec verify`. Distinct from `verifications`/`taskVerifications` above (per-scope history with attempts/nodes): this is overwritten by every verify call regardless of scope, carrying just enough to correlate a pass/fail with the exact commit it covers. Primary consumer: `morph-spec gate-check`, which feeds `status` to shouldPauseGate as `verificationStatus` — a red verify pauses the gate (risk-detector.js) — and `nodes.e2e`/`e2eReason` as `e2eStatus`/`e2eReason`, the pair that decides whether an e2e SKIP is a recorded dispensation or an absence of proof (a skip never reddens `status`, so the rollup alone is blind to it). `scope` is the third pause input: a dispensation only counts from a feature-scoped stamp (see the field below). The rest (sha/ts, and the derived `verificationStale`) is audit context.",
      "required": ["status", "sha", "ts", "scope"],
      "properties": {
        "status": { "type": "string", "enum": ["pass", "fail"] },
        "nodes": {
          "type": "object",
          "description": "Per-node status of the verify that wrote this stamp. NOT in `required`: stamps written before the field existed carry only the rollup. Same values as `verifications.feature.nodes`, repeated here because gate-check reads the stamp and nothing else.",
          "properties": {
            "build": { "type": "string", "enum": ["pass", "fail", "skip"] },
            "tests": { "type": "string", "enum": ["pass", "fail", "skip"] },
            "validators": { "type": "string", "enum": ["pass", "fail", "skip"] },
            "e2e": { "type": "string", "enum": ["pass", "fail", "skip"] },
            "evals": {
              "type": ["string", "null"],
              "enum": ["pass", "fail", "skip", null],
              "description": "Status of the `runner: \"evals\"` entries of nodes.tests.runs[], pulled out of the rollup. The rollup cannot answer what Gate 3 asks: a red eval and a red unit test both read as tests:fail, and an eval SKIPPED at task scope leaves the rollup green. null means this project declares NO eval node - distinct from 'skip', which means it declares one and this run did not execute it; risk-detector.js pauses on the two for different reasons because they call for different actions. Absent on stamps written before this field existed."
            }
          },
          "additionalProperties": false
        },
        "e2eReason": { "type": ["string", "null"], "description": "The LITERAL reason the e2e node skipped (e2e-check.js's own wording), or null when it did not skip. risk-detector.js's classifyE2eSkip() reads it: 'project does not containerize' / 'does not touch runtime' are legitimate dispensations and pass; 'docker is not available' / 'requested' (--skip-e2e) / anything unclassifiable — null included — are an absence of proof and pause Gate 3. A skip recorded WITHOUT a reason is refused on purpose: a nameless dispensation is a silent no-op." },
        "sha": { "type": ["string", "null"], "description": "HEAD commit SHA at verify time (git rev-parse HEAD); null when not in a git repo or no commits yet." },
        "ts": { "type": "string", "format": "date-time", "description": "Server-side timestamp, same instant as the corresponding verifications/taskVerifications entry's updatedAt." },
        "scope": { "type": "string", "description": "'feature', or 'task:<id>' for a task-scoped verify. READ BY THE GATE, not just audit: this stamp is singular and any verify overwrites it, so a 'task:<id>' value at ship time means the last verify was NOT the feature's. risk-detector.js therefore accepts an e2e dispensation ('project'/'scope' in e2eReason) only from scope === 'feature' — a `morph-spec verify {f} {task}` of a task whose group does not touch runtime stamps exactly the legitimate-sounding reason that would otherwise buy Gate 3 for the whole feature (the last task of a feature is typically group: docs). Absent/unknown falls on the safe side, like an unclassifiable reason." }
      },
      "additionalProperties": false
    },
    "tasks": {
      "type": "object",
      "description": "Aggregate task counters (informational — the source of truth is 2-plan/tasks.json).",
      "properties": {
        "total": { "type": "integer", "minimum": 0 },
        "completed": { "type": "integer", "minimum": 0 },
        "inProgress": { "type": "integer", "minimum": 0 },
        "pending": { "type": "integer", "minimum": 0 }
      },
      "additionalProperties": true
    },
    "outputs": {
      "type": "object",
      "description": "Outputs tracked by the state-sync hook: filename → { created, path }.",
      "additionalProperties": {
        "type": "object",
        "properties": {
          "created": { "type": "boolean" },
          "path": { "type": "string" }
        },
        "additionalProperties": true
      }
    },
    "workType": {
      "type": "string",
      "enum": ["feature", "bug", "chore", "hotfix"],
      "description": "Roteamento por tipo (v8). Ausente = 'feature'. Escrito só por `morph-spec create --type`/`retype` (shape em src/core/work-types.js)."
    },
    "workTypeDetection": {
      "type": "object",
      "description": "Auditoria de como workType foi resolvido, escrita só por `morph-spec create`. Todos os campos são opcionais.",
      "properties": {
        "source": { "type": "string", "enum": ["deterministic", "llm", "user"] },
        "confidence": { "type": "string", "enum": ["high", "tie", "none"] },
        "matched": { "type": "object", "description": "Keywords casados por tipo candidato (src/lib/work-type-detector.js)." },
        "decidedAt": { "type": "string", "format": "date-time" }
      },
      "additionalProperties": true
    },
    "workTypeHistory": {
      "type": "array",
      "description": "Histórico de reclassificações, escrito por `morph-spec retype` (task futura).",
      "items": {
        "type": "object",
        "properties": {
          "from": { "type": "string" },
          "to": { "type": "string" },
          "reason": { "type": "string" },
          "at": { "type": "string", "format": "date-time" }
        },
        "additionalProperties": true
      }
    },
    "worktree": {
      "type": "object",
      "description": "Bookkeeping do worktree ativo desta feature no ROOT feature.json, gravado por `morph-spec worktree setup` (via `create --worktree` ou standalone). Removido no fechamento por `morph-spec finish` (que absorveu o antigo `worktree finish`).",
      "properties": {
        "path": { "type": "string" },
        "branch": { "type": "string" },
        "createdAt": { "type": "string", "format": "date-time" }
      },
      "additionalProperties": true
    },
    "checkpoints": { "type": "array" },
    "trustConfig": { "type": "object" },
    "contextBundles": { "type": "array" },
    "fileChanges": { "type": "array" },
    "skippedPhases": { "type": "array" }
  },
  "additionalProperties": true,
  "definitions": {
    "gate": {
      "type": "object",
      "required": ["approved"],
      "properties": {
        "approved": { "type": "boolean" },
        "timestamp": { "type": ["string", "null"], "description": "ISO timestamp of the approval/revocation, null while never evaluated." },
        "approvedBy": { "type": ["string", "null"], "description": "\"auto\" (approve/advance --mode auto), \"manual\" (human in the loop), \"policy:{workType}\" (gate stamped at birth by `morph-spec create` per the work type's skippedGates — see src/core/work-types.js; the gate-guard hook only special-cases \"auto\", so policy-stamped gates stay silent to it), a --approver name, or null while never evaluated." },
        "selfAssessed": { "type": "boolean", "description": "Gate `review` SOMENTE. `true` quando o Gate 3 foi aprovado sem `4-review/evaluator-report.md`, via `morph-spec approve <f> review --self-assessed`. O score composto deveria vir do avaliador independente; esta flag registra a degradação em vez de deixá-la silenciosa, e aparece no `morph-spec list`." }
      },
      "additionalProperties": true
    }
  }
}
