{
  "$schema": "https://json-schema.org/draft/2020-12/schema",
  "$id": "https://github.com/homeofe/AAHP/schema/aahp-config.schema.json",
  "title": "AAHP Config",
  "description": "Optional aahp.config.json consumed by the config-driven AAHP release gates, aahp doctor, and the verify gate's reviewed handoff-impact classifier. Every section is optional; absent configuration preserves each command's default behavior.",
  "type": "object",
  "properties": {
    "$schema": {
      "type": "string",
      "description": "Optional JSON Schema reference for editor tooling."
    },
    "versionSites": {
      "type": "array",
      "description": "Files that must contain the current package.json version at least minOccurrences times (checked by check-version-sync.mjs).",
      "items": {
        "type": "object",
        "required": [
          "file"
        ],
        "properties": {
          "file": {
            "type": "string",
            "description": "Path relative to the project root."
          },
          "minOccurrences": {
            "type": "integer",
            "minimum": 1,
            "description": "Minimum number of occurrences required (default 1)."
          },
          "boundary": {
            "type": "boolean",
            "description": "If true, match on word boundaries so 3.3.0 does not match inside 3.3.09 (default false: literal substring)."
          },
          "note": {
            "type": "string",
            "description": "Human-readable reason this file pins the version."
          }
        },
        "additionalProperties": false
      }
    },
    "claims": {
      "type": "array",
      "description": "Capability numbers pinned to one canonical value across surfaces (checked by check-claims.mjs).",
      "items": {
        "type": "object",
        "required": [
          "id",
          "canonical",
          "advertised",
          "phrase",
          "surfaces"
        ],
        "properties": {
          "id": {
            "type": "string",
            "description": "Short identifier for the claim (used in messages)."
          },
          "canonical": {
            "type": "string",
            "description": "Token shown to humans, e.g. \"350+\"."
          },
          "advertised": {
            "type": "integer",
            "description": "The number every surface must carry."
          },
          "phrase": {
            "type": "string",
            "description": "Regex source; capture group 1 is the number to compare."
          },
          "flags": {
            "type": "string",
            "description": "Regex flags (default \"gi\")."
          },
          "floorCmd": {
            "type": "string",
            "description": "Optional repo-relative path to a Node script whose stdout yields an integer ground-truth floor; advertised must not exceed it. Run with the Node interpreter, no shell (a path escaping the project root is rejected). Omit for an editorial claim."
          },
          "joinTsConcat": {
            "type": "boolean",
            "description": "If true, join adjacent JS string-literal concatenations before matching."
          },
          "surfaces": {
            "type": "array",
            "items": {
              "type": "object",
              "required": [
                "file"
              ],
              "properties": {
                "file": {
                  "type": "string"
                },
                "note": {
                  "type": "string"
                }
              },
              "additionalProperties": false
            }
          }
        },
        "additionalProperties": false
      }
    },
    "generate": {
      "type": "object",
      "description": "Handoff generator + freshness gate (aahp-dashboard.mjs).",
      "properties": {
        "log": {
          "type": "object",
          "description": "Generate a release journal from a CHANGELOG into its own file. Opt-in. The generator overwrites its target, so it refuses the append-only agent journal.",
          "properties": {
            "source": {
              "type": "string",
              "description": "CHANGELOG path relative to the project root (default CHANGELOG.md)."
            },
            "target": {
              "type": "string",
              "description": "Output path relative to the project root, for example docs/RELEASES.md. Required when generate.log is set; the generator refuses .ai/handoff/LOG.md, LOG-ARCHIVE.md and LOG-ARCHIVE.index.json."
            },
            "title": {
              "type": "string",
              "description": "H1 title for the generated journal."
            }
          },
          "additionalProperties": false
        },
        "freshness": {
          "type": "object",
          "description": "Check that a hand-curated doc's 'Current version: **vX.Y.Z**' line matches package.json.",
          "properties": {
            "file": {
              "type": "string",
              "description": "Path relative to the project root (default .ai/handoff/NEXT_ACTIONS.md)."
            }
          },
          "additionalProperties": false
        }
      },
      "additionalProperties": false
    },
    "forbiddenPatterns": {
      "type": "array",
      "description": "Regexes that must NOT appear in tracked files (checked by check-forbidden-patterns.mjs).",
      "items": {
        "type": "object",
        "required": [
          "pattern"
        ],
        "properties": {
          "id": {
            "type": "string"
          },
          "pattern": {
            "type": "string",
            "description": "Regex source. Store a literal-unicode ban as an escape (e.g. \\u2014) so the config file itself does not match."
          },
          "flags": {
            "type": "string",
            "description": "Regex flags (default \"gm\")."
          },
          "message": {
            "type": "string"
          },
          "include": {
            "type": "array",
            "items": {
              "type": "string"
            },
            "description": "git pathspecs to scan (default: common text files)."
          },
          "exclude": {
            "type": "array",
            "items": {
              "type": "string"
            }
          }
        },
        "additionalProperties": false
      }
    },
    "docSync": {
      "type": "array",
      "description": "Groups of sources whose extracted value-set must agree (checked by check-schema-doc-sync.mjs).",
      "items": {
        "type": "object",
        "required": [
          "sources"
        ],
        "properties": {
          "id": {
            "type": "string"
          },
          "sources": {
            "type": "array",
            "items": {
              "type": "object",
              "required": [
                "file",
                "pattern"
              ],
              "properties": {
                "file": {
                  "type": "string"
                },
                "pattern": {
                  "type": "string",
                  "description": "Regex; capture group 1 is one value. The set of group-1 values must match across all sources in the group."
                }
              },
              "additionalProperties": false
            }
          }
        },
        "additionalProperties": false
      }
    },
    "docLinks": {
      "type": "object",
      "description": "Internal Markdown link checker (check-doc-links.mjs). Presence enables it.",
      "properties": {
        "include": {
          "type": "array",
          "items": {
            "type": "string"
          },
          "description": "git pathspecs of docs to check (default: README.md, CLAUDE.md, CONTRIBUTING.md, .ai/handoff/*.md)."
        }
      },
      "additionalProperties": false
    },
    "docPaths": {
      "type": "object",
      "description": "Shape assertions over the adopter-facing documents (check-doc-shape.mjs). Presence enables it. Backticked repo-relative paths whose first segment is a tracked top-level entry of this repository must resolve against the git index; paths that intentionally describe a CONSUMER's tree are declared in adopterPaths with a reason and are reported as not assessed on every run. The gate exits 2 on anything it could not assess, so an unreadable tree never reports clean.",
      "properties": {
        "include": {
          "type": "array",
          "items": {
            "type": "string"
          },
          "description": "git pathspecs of documents to scan (default: README.md, CLAUDE.md, CONTRIBUTING.md)."
        },
        "adopterPaths": {
          "type": "array",
          "description": "Reviewed exceptions: paths that resolve in an adopting repository rather than in this one. Each entry pins the exact number of occurrences it covers, so a NEW mention of the same string is a failure rather than a silent exemption, and an entry that matches nothing is a failure too. The same string is often right in one sentence and wrong in the next, which is why this is a counted change detector and not an allowlist.",
          "items": {
            "type": "object",
            "required": [
              "path",
              "occurrences",
              "reason"
            ],
            "properties": {
              "path": {
                "type": "string",
                "minLength": 1,
                "description": "The exact span as it appears between backticks, without a leading ./ or a trailing /."
              },
              "occurrences": {
                "type": "integer",
                "minimum": 1,
                "description": "Exact number of reviewed occurrences across the scanned documents. Any other number is a finding, in both directions."
              },
              "reason": {
                "type": "string",
                "minLength": 1,
                "description": "Why this path is correct while naming nothing in this repository."
              }
            },
            "additionalProperties": false
          }
        },
        "requiredHeadings": {
          "type": "array",
          "description": "Headings that must exist, optionally before another heading. A missing heading is a failure; an anchor that itself cannot be found is a failure too, because an ordering that cannot be judged is not a pass.",
          "items": {
            "type": "object",
            "required": [
              "id",
              "file",
              "pattern"
            ],
            "properties": {
              "id": {
                "type": "string",
                "description": "Short identifier used in messages."
              },
              "file": {
                "type": "string",
                "description": "Tracked file to search, relative to the project root."
              },
              "pattern": {
                "type": "string",
                "description": "Regex matched line by line; the first matching line is the heading."
              },
              "before": {
                "type": "string",
                "description": "Optional regex naming a line the heading must precede."
              },
              "note": {
                "type": "string",
                "description": "Human-readable consequence, printed when the heading is missing."
              }
            },
            "additionalProperties": false
          }
        }
      },
      "additionalProperties": false
    },
    "acceptanceCriteria": {
      "type": "object",
      "description": "Input paths for the ADVISORY `aahp criteria` report (report-acceptance-criteria.mjs). This is not a gate: it is not part of `aahp check`, it has no enforcing mode, and it always exits 0. It reports legacy heading aliases, plain bullets where task boxes belong, criteria left unresolved on tasks the manifest marks done, and everything it could not read. Optional: absent, the report uses these defaults. A clean report is not proof; see README Section 8.7 for the published blind spots.",
      "properties": {
        "include": {
          "type": "array",
          "items": {
            "type": "string"
          },
          "description": "git pathspecs of documents to scan (default: .ai/handoff/NEXT_ACTIONS.md). List live task documents, not templates: a template task id can collide with a real task id in the manifest."
        },
        "manifest": {
          "type": "string",
          "description": "Path to the task registry that supplies task status (default .ai/handoff/MANIFEST.json), relative to the project root and required to resolve inside it. A value that escapes the root is reported as manifest-outside-root and is never opened. Absent, the done-state rule is skipped rather than guessed."
        }
      },
      "additionalProperties": false
    },
    "handoffImpact": {
      "type": "object",
      "description": "Reviewed exceptions for the Layer 2 content-drift gate. nonImpactingModifiedFiles: only a modification (git status M) of a listed regular tracked file is non-impacting; additions, deletions, renames, copies, type changes, and mixed source changes remain impacting. npmDevDependencyUpdates: an opt-in, content-based classification of registry-pinned devDependency lockfile updates. At least one of the two must be present.",
      "anyOf": [
        {
          "required": [
            "nonImpactingModifiedFiles"
          ]
        },
        {
          "required": [
            "npmDevDependencyUpdates"
          ]
        }
      ],
      "properties": {
        "npmDevDependencyUpdates": {
          "type": "object",
          "description": "Opt-in. A change set whose only files outside .ai/handoff/ are a content modification of package-lock.json (optionally with package.json) is non-impacting when every changed lock entry is a node_modules/ entry that is dev: true before and after, has no install script (hasInstallScript: true, new or kept, disqualifies it), carries a sha256/384/512 integrity and is resolved under https://registry.npmjs.org/, every other top-level lock key is unchanged, and package.json and the lock root entry differ only in devDependencies version specifiers. Decided by content, never by the change's author. Requires supplyChainScan, which the gate proves against the inspected snapshot on every run.",
          "required": [
            "reason",
            "supplyChainScan"
          ],
          "properties": {
            "reason": {
              "type": "string",
              "minLength": 1,
              "pattern": "[\\p{L}\\p{N}]",
              "not": {
                "pattern": "[\\p{Cc}\\p{Cf}]"
              },
              "description": "Review rationale, logged whenever the classification is applied. It must contain a Unicode letter or number and cannot contain control or invisible format characters."
            },
            "supplyChainScan": {
              "type": "object",
              "description": "The supply-chain scanner the owner tied this exemption to. The gate verifies that the workflow is a regular tracked file, has pull_request among its top-level on: triggers (pull_request_target does not count), defines the job, that a job-level if:, when the job has one, names pull_request in its text (a job with no if: passes; if: always() is refused), and that the job is not continue-on-error. It cannot verify that the job is a REQUIRED status check, which is a repository setting.",
              "required": [
                "workflow",
                "job"
              ],
              "properties": {
                "workflow": {
                  "type": "string",
                  "pattern": "^\\.github/workflows/[A-Za-z0-9_-][A-Za-z0-9._-]*\\.ya?ml$",
                  "description": "Workflow file, relative to the repository top level (where GitHub reads workflows), directly under .github/workflows/."
                },
                "job": {
                  "type": "string",
                  "pattern": "^[A-Za-z_][A-Za-z0-9_-]{0,99}$",
                  "description": "The job id (the key under jobs:), not its display name."
                }
              },
              "additionalProperties": false
            }
          },
          "additionalProperties": false
        },
        "nonImpactingModifiedFiles": {
          "type": "array",
          "description": "Exact repo-relative regular tracked files whose content-only modification does not change handoff state. Runtime validation rejects duplicates and prefix-like ambiguity and rejects directories, symlinks, and gitlinks.",
          "items": {
            "type": "object",
            "required": [
              "file",
              "reason"
            ],
            "properties": {
              "file": {
                "type": "string",
                "minLength": 1,
                "pattern": "^[A-Za-z0-9._@+ -]+(?:/[A-Za-z0-9._@+ -]+)*$",
                "not": {
                  "anyOf": [
                    {
                      "pattern": "^aahp\\.config\\.json$"
                    },
                    {
                      "pattern": "^\\.ai/handoff(?:/|$)"
                    },
                    {
                      "pattern": "(?:^|/)\\.{1,2}(?:/|$)"
                    }
                  ]
                },
                "description": "Exact repo-relative regular tracked file. Forward slashes only; no absolute paths, traversal, glob syntax, directories, symlinks, gitlinks, handoff files, or aahp.config.json."
              },
              "reason": {
                "type": "string",
                "minLength": 1,
                "pattern": "[\\p{L}\\p{N}]",
                "not": {
                  "pattern": "[\\p{Cc}\\p{Cf}]"
                },
                "description": "Review rationale explaining why modifying this file cannot stale handoff state. It must contain a Unicode letter or number and cannot contain control or invisible format characters."
              }
            },
            "additionalProperties": false
          }
        }
      },
      "additionalProperties": false
    },
    "check": {
      "type": "object",
      "description": "Selects which gates `aahp check` runs. Both lists are optional; omit to run every applicable gate. Gate ids: changelog, changelog-format, version-sync, claims, forbidden-patterns, schema-doc-sync, doc-links, handoff.",
      "properties": {
        "only": {
          "type": "array",
          "items": {
            "type": "string"
          },
          "description": "Run only these gate ids (whitelist)."
        },
        "skip": {
          "type": "array",
          "items": {
            "type": "string"
          },
          "description": "Skip these gate ids (blacklist), applied after only."
        }
      },
      "additionalProperties": false
    },
    "pinnedDep": {
      "type": "object",
      "description": "Opt-in distribution-pin assertion for the aahp doctor pinned-dep gate. Absent: the gate reports skip. A repo whose own package name equals name reports self.",
      "properties": {
        "name": {
          "type": "string",
          "description": "Package that must be pinned (default @elvatis_com/aahp)."
        },
        "location": {
          "type": "string",
          "enum": [
            "devDependencies",
            "dependencies",
            "any"
          ],
          "description": "Which dependency block must carry the pin (default devDependencies)."
        },
        "allowRange": {
          "type": "boolean",
          "description": "If true, accept a semver range; default false requires an exact pin."
        }
      },
      "additionalProperties": false
    },
    "trustTtl": {
      "type": "object",
      "description": "Policy for aahp verify Layer 4 (TRUST-TTL). A 'verified' TRUST.md row that names a check in a Check column is judged by that check on every run, not by its date; any other 'verified' row is judged by its Expires date. Absent or enforce:false: every Layer 4 finding warns and the run still passes, which is the historical behaviour. enforce:true: a failing check fails the run, a date-judged row fails once it is more than graceDays past its expiry (it warns before that), and a register with no row that can be judged fails as well, since an unreadable register is not a clean one.",
      "properties": {
        "enforce": {
          "type": "boolean",
          "description": "If true, Layer 4 findings past their grace make aahp verify exit non-zero. Default false. Layer 4 does not run at precommit level, so this gates CI rather than local commits, and the pull request that refreshes TRUST.md carries the refreshed rows with it."
        },
        "graceDays": {
          "type": "integer",
          "minimum": 0,
          "not": {
            "type": "integer",
            "minimum": 366
          },
          "description": "From 0 to 365. Days a date-judged 'verified' row may be past its Expires date and still only warn under enforce. Default 14 (two weekly Dependabot cycles, so at least two routine runs print the expiry before it blocks). 0 blocks from the day after expiry."
        },
        "checks": {
          "type": "array",
          "description": "Reviewed, executable trust checks a TRUST.md row may name by id in its Check column. Each run is an argv executed without a shell, in the project root, with a 120-second limit; exit 0 re-proves the row. TRUST.md only ever supplies the id, never a command. Built-in ids (license-matches, manifest-integrity) are reserved.",
          "items": {
            "type": "object",
            "required": [
              "id",
              "run",
              "reason"
            ],
            "properties": {
              "id": {
                "type": "string",
                "pattern": "^[a-z0-9][a-z0-9-]{0,63}$",
                "not": {
                  "enum": [
                    "license-matches",
                    "manifest-integrity"
                  ]
                },
                "description": "The name a TRUST.md Check cell uses. Unique; may not reuse a built-in id."
              },
              "run": {
                "type": "array",
                "items": {
                  "type": "string",
                  "minLength": 1,
                  "not": {
                    "pattern": "[\\p{Cc}]"
                  }
                },
                "description": "argv: the program, then its arguments, each a non-empty string without control characters. Not passed to a shell, so no expansion, globbing, pipes or redirection. The runtime also refuses an empty array (this schema's validator has no minItems)."
              },
              "reason": {
                "type": "string",
                "minLength": 1,
                "pattern": "[\\p{L}\\p{N}]",
                "not": {
                  "pattern": "[\\p{Cc}\\p{Cf}]"
                },
                "description": "Why a zero exit proves the claim the row makes."
              }
            },
            "additionalProperties": false
          }
        }
      },
      "additionalProperties": false
    },
    "verifyWorkflow": {
      "type": "object",
      "description": "Opt-in enforcement for the aahp doctor verify-workflow gate. Absent or enforce:false: a workflow that can skip the gate is reported as 'advisory' and the exit code is unchanged. enforce:true: it fails the run. Default off because the finding is pre-existing and deliberate in most adopting repositories, and clearing it requires a workflow edit no pull-request author can make from their own pull request.",
      "properties": {
        "enforce": {
          "type": "boolean",
          "description": "If true, a verify workflow that can be skipped fails aahp doctor. Default false, which reports the finding without changing the exit code."
        }
      },
      "additionalProperties": false
    }
  },
  "additionalProperties": false
}
