@deftai/directive-content 0.56.2 → 0.57.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/packs/skills/skills-pack-0.1.json +2 -2
- package/scripts/doctor.py +394 -100
- package/skills/deft-directive-release/SKILL.md +43 -27
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@deftai/directive-content",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.57.0",
|
|
4
4
|
"description": "Shippable Directive framework content in the consumer .deft/core/ layout (C1 flatten), plus the engine surfaces (.githooks/, Taskfile.yml, tasks/, scripts/) the deposit wires. Refs #11, #1669, #1967.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"files": [
|
|
@@ -168,7 +168,7 @@
|
|
|
168
168
|
},
|
|
169
169
|
{
|
|
170
170
|
"id": "deft-directive-release",
|
|
171
|
-
"description": "Cut a v0.X.Y release of the deft framework safely. Use when the user says \"release\", \"cut release\", \"v0.X.Y\", or \"publish release\" -- to walk an 8-phase workflow that pre-flights, runs an end-to-end rehearsal against a temp repo, lands a draft
|
|
171
|
+
"description": "Cut a v0.X.Y release of the deft framework safely. Use when the user says \"release\", \"cut release\", \"v0.X.Y\", or \"publish release\" -- to walk an 8-phase workflow that pre-flights, runs an end-to-end rehearsal against a temp repo, lands a draft GitHub release (npm ships irrevocably at tag push), optionally QA's draft assets, then publishes the GitHub release or rolls back. Re-uses the deft-directive-swarm Phase 6 Step 5 Slack announcement template.",
|
|
172
172
|
"triggers": [
|
|
173
173
|
"release",
|
|
174
174
|
"cut release",
|
|
@@ -177,7 +177,7 @@
|
|
|
177
177
|
],
|
|
178
178
|
"path": "skills/deft-directive-release/SKILL.md",
|
|
179
179
|
"version": "0.1",
|
|
180
|
-
"body": "# Deft Directive Release\n\nStructured 8-phase workflow for cutting a v0.X.Y release of the deft framework. Operationalizes the `task release` / `task release:publish` / `task release:rollback` / `task release:e2e` surface introduced in #716 (safety hardening of #74).\n\nLegend (from RFC2119): !=MUST, ~=SHOULD, ≉=SHOULD NOT, ⊗=MUST NOT, ?=MAY.\n\n**See also**: [deft-directive-swarm](../deft-directive-swarm/SKILL.md) Phase 6 Step 5 (Slack announcement template re-used by Phase 8 below) | [deft-directive-review-cycle](../deft-directive-review-cycle/SKILL.md) (user-gate pattern) | [deft-directive-refinement](../deft-directive-refinement/SKILL.md) (conversational phased flow).\n\n## Platform Requirements\n\n! GitHub as the SCM platform; the **GitHub CLI (`gh`)** must be installed and authenticated. The full pipeline plus the rehearsal target (`task release:e2e`) all dispatch through `gh`.\n\n## Branch-Protection Policy Guard\n\n! Before any Phase 1 state mutation, run the skill-level branch-policy guard documented in `scripts/policy.py` / `scripts/preflight_branch.py` (#746 / #747). Releases run on the configured base branch (default `master`), so the operator MUST be on the explicit-opt-in side of the policy before the pipeline starts writing files.\n\n**Preferred path — typed direct-commit policy opt-out (#1553).** For a release session on the default branch, prefer the audited typed flag over the emergency env-var bypass:\n\n```\ntask policy:allow-direct-commits -- --confirm\n```\n\nThis writes `plan.policy.allowDirectCommitsToMaster = true` on `vbrief/PROJECT-DEFINITION.vbrief.json` with a capability-cost disclosure. After the release completes (or if the session aborts), restore enforcement:\n\n```\ntask policy:enforce-branches\n```\n\n**Branch-guard probe (either path).** Regardless of which opt-out path you chose, confirm the guard passes before Phase 1 mutates state:\n\n```\nuv run python scripts/preflight_branch.py --project-root . --quiet || exit 1\n```\n\nor invoke `task verify:branch`. This is the canonical surface that surfaces the policy state to the operator before the pipeline starts writing files. The release pipeline's other safety surfaces (the dirty-tree guard, base-branch check, `task ci:local` gate) remain independent of this check.\n\n**Emergency env-var bypass — narrow scope only (#1553).** `DEFT_ALLOW_DEFAULT_BRANCH_COMMIT=1` is process-wide: every child process, nested test, and temporary repository spawned from the same shell inherits it. During the v0.43.0 release attempt, wrapping the entire `task release` invocation in this env var let the bypass leak into the Step 5 `task ci:local` preflight, which caused `TestWriteConsumerGitHooks_VendoredCommitBlocked_RealGit` to fail because the vendored test repo allowed a direct `master` commit the test expected the hook to block.\n\n- ! Prefer `task policy:allow-direct-commits -- --confirm` for release sessions instead of exporting `DEFT_ALLOW_DEFAULT_BRANCH_COMMIT=1` for the whole shell.\n- ⊗ Wrap `task release`, `task ci:local`, or `task check` in `DEFT_ALLOW_DEFAULT_BRANCH_COMMIT=1` -- the env var is inherited by every subprocess and can produce false preflight failures before any release mutation.\n- ? If the env-var path is unavoidable, scope it to a **single** branch-guard probe only (e.g. `DEFT_ALLOW_DEFAULT_BRANCH_COMMIT=1 task verify:branch`) and do NOT export it for the release session. The release pipeline itself passes the bypass only in scoped subprocess `env=` for its authorised commit/tag/push mutations (#867); operators MUST NOT mirror that pattern at the shell level.\n\nThe release pipeline's Step 9/10/11 git mutations carry the bypass in subprocess `env=` only (`scripts/release.py::_release_subprocess_env`, #867) so the parent shell stays clean. Operator-side env-var exports defeat that isolation.\n\n## Deterministic Questions Contract\n\n! Every numbered-menu prompt rendered in this skill (Phase 1 version-bump magnitude check, Phase 2 dry-run review `yes`/`back`/`quit`, Phase 5 `publish`/`rollback`/`defer`) MUST follow [`../../contracts/deterministic-questions.md`](../../contracts/deterministic-questions.md): the final two numbered options MUST be `Discuss` and `Back`, in that order. Existing `back`/`quit` options remain valid; this contract simply adds `Discuss` as a peer alongside `Back`. The Discuss-pause semantic is documented verbatim in the contract -- implicit resumption is forbidden.\n\n## When to Use\n\n- User says \"release\", \"cut release\", \"v0.X.Y\", \"publish release\", \"ship a release\"\n- The framework's `[Unreleased]` CHANGELOG section is non-empty and the operator wants to cut a tagged release\n- A previous release rehearsal succeeded and the operator is ready for the production cut\n\n## Phase 1 — Pre-flight\n\n! Validate the local + remote state before any irreversible action.\n\n1. ! Verify the operator is on the configured base branch (default `master`) and the working tree is clean\n2. ! Confirm the next version number (`X.Y.Z`) with the user. Major / minor / patch decision flows from the `[Unreleased]` content (breaking change → major; new feature → minor; fix-only → patch)\n3. ! Inspect `[Unreleased]` content vs the proposed version bump. If a breaking change appears in `### Changed` / `### Removed` but only a patch is proposed, surface the mismatch and ask the user to choose\n4. ! Verify `task ci:local` passes locally (or `task check` as the graceful-degradation fallback per `tasks/release.yml` line 9-10). The `task release` script will refuse to proceed otherwise -- but Phase 1 catches it earlier\n5. ! Verify `gh auth status` reports authenticated (`task release` will refuse otherwise)\n6. ! **Run `task reconcile:issues -- --apply-lifecycle-fixes` to clear any closed-issue / non-completed-folder vBRIEFs before invoking `task release`** (#734). The release pipeline carries the deterministic gate at Step 3 (`scripts/release.py::check_vbrief_lifecycle_sync`, refuses with `EXIT_VIOLATION` on any Section (c) mismatch), but Phase 1 is the operator's first-line defence -- running the apply-mode flag here is the canonical clean path; `--allow-vbrief-drift` on the pipeline exists only as the explicit-acknowledgment escape hatch (analogous to `--allow-dirty`). The recurrence record is the v0.21.0 cut, which surfaced 13 stranded vBRIEFs (8 cycle-relevant + 5 historical residue) post-publish; the gate now blocks that drift before any irreversible action\n7. ! **Verify the proposed `v<version>` tag is not already in use locally, on origin, or as a published GitHub release** (#784). The release pipeline carries the deterministic gate at Step 4 (`scripts/release.py::check_tag_available`, refuses with `EXIT_VIOLATION` before any state mutation -- CHANGELOG promotion, ROADMAP refresh, build, commit), but Phase 1 is the operator's first-line defence. Quickly probe with `git tag -l v<version>` (local), `git ls-remote --tags origin refs/tags/v<version>` (remote), and `gh release view v<version> --repo <owner>/<repo>` (release-only, where `gh release view` exits 0 only when the release exists). The recurrence record is the v0.22.0 → v0.23.0 release attempt on 2026-05-01: the operator typed `0.22.0` (the prior release from 12 hours earlier) and the legacy pipeline ran 8 steps before failing at `git tag` -- leaving a wrong-version local commit + `dist/deft-0.22.0.zip` orphan + manual `git reset --hard` recovery. The new pre-flight gate blocks that mode before any irreversible action\n8. ! **Verify the npm credential path is configured before cutting the tag** (#1910, #1909). A `v*` tag now auto-triggers `.github/workflows/npm-publish.yml`, which publishes the four `@deftai/directive*` packages with `npm publish --provenance`. Confirm the publish path can authenticate: either the `NPM_TOKEN` repo secret is present (`gh secret list --repo <owner>/<repo>` shows `NPM_TOKEN`) OR an npm OIDC trusted publisher is configured for the `@deftai/directive*` packages. If neither is in place, WARN loudly that the tag will fire a publish job that fails (red X on the tag, no packages) -- the operator may still proceed for a GitHub-only release, but the npm channel will not land until #1909's credential is provisioned. Cross-reference #1909.\n9. ~ Ask the operator for an optional one-line release **summary** (recommended 80-160 chars; can be skipped). The summary is the canonical narrative for THIS release across three audiences: (a) injected as a Markdown blockquote at the top of the promoted `CHANGELOG.md [<version>]` section, (b) auto-flowed into the GitHub release body via the existing `_section_for_version` pickup, and (c) populated VERBATIM into the Phase 8 Slack `*Summary*:` slot. Capture the wording once here; do NOT regenerate per-audience downstream\n\n⊗ Skip the version-bump magnitude check -- a patch release that ships breaking changes is the kind of regression that Repair Authority [AXIOM] (#709) is designed to prevent.\n\n⊗ Skip the vBRIEF-lifecycle-sync check (#734); the gate exists because operators consistently forget the manual `task scope:complete` move step. The v0.21.0 cut surfaced 13 stranded vBRIEFs (8 cycle-relevant + 5 historical residue) post-publish as the recurrence record this gate prevents. If `task release` reports `[3/13] Pre-flight vBRIEF lifecycle sync... FAIL (<count> mismatches; run task reconcile:issues -- --apply-lifecycle-fixes to fix)`, the canonical recovery is the apply-mode invocation -- `--allow-vbrief-drift` is reserved for cases where the operator has explicitly reviewed the drift and chosen to defer the lifecycle reconcile to the next refinement pass (e.g. an emergency hot-fix release).\n\n⊗ Skip the tag-availability check (#784); the gate exists because the legacy 12-step pipeline only invoked `git tag` at Step 9, after Steps 1-8 had already mutated state (CHANGELOG promoted, ROADMAP refreshed, dist built, release commit made locally). A duplicate-tag failure at Step 9 stranded the operator with an unpushed wrong-version commit + orphaned `dist/deft-<wrong>.zip` artifact + manual `git reset --hard` recovery (forbidden by AGENTS.md SCM rules without explicit permission). The recurrence record is the v0.22.0 → v0.23.0 release attempt on 2026-05-01. If `task release` reports `[4/13] Pre-flight tag availability... FAIL (<surface> tag v<version> already exists ...)`, the canonical recovery is to choose a different version (the most likely cause is operator typo of a prior release).\n\n⊗ Hand-write a different one-line narrative for each of the three downstream surfaces (CHANGELOG / GitHub release / Slack) -- that drift is exactly the gap the `--summary` flag is designed to close. If the operator insists on per-audience tone, populate the canonical `--summary` ONCE here and document the deviation in the Phase 8 anti-pattern.\n\n## Phase 2 — Dry-run review\n\n! Invoke `task release -- <version> --dry-run --skip-tag --skip-release` and present the plan to the user. If Phase 1 collected an operator summary, also pass `--summary \"<text>\"` so the dry-run preview reflects the canonical narrative the operator just authored.\n\n```\ntask release -- <version> --dry-run --skip-tag --skip-release --summary \"<text>\"\n```\n\nThe dry-run prints `[N/13] <step>... DRYRUN (would <action>)` for every pipeline step (Step 13 is the post-create verify-isDraft gate added by #724; Step 4 is the tag-availability pre-flight gate added by #784). Step 6 (CHANGELOG promotion) surfaces whether a summary was supplied (truncated to ~60 chars in the preview) so the operator can validate the wording before any file is written. Capture the output and present it to the user, then wait for explicit confirmation before continuing.\n\n! Wait for explicit user confirmation: `yes` / `back` / `quit`.\n- `yes` (or `confirmed` / `approve`) → proceed to Phase 3\n- `back` → return to Phase 1 for re-validation (e.g. user wants to amend the version or `[Unreleased]` content)\n- `quit` → abort the workflow cleanly; no state changes\n\n⊗ Skip the dry-run preview. The dry-run is the operator's last opportunity to catch a bad version number, malformed CHANGELOG, or wrong base branch before the pipeline starts writing files.\n\n## Phase 3 — E2E sanity\n\n! Invoke `task release:e2e` against an auto-created+destroyed temp repo to verify the full pipeline shape works end-to-end before touching the real repo.\n\n```\ntask release:e2e\n```\n\nThe harness provisions `deftai/deftai-release-test-<ts>-<uuid6>`, runs the smoke-test rehearsal, and destroys the temp repo in a `try/finally` clause. Cleanup runs even if the rehearsal fails. If `gh repo delete` fails, surface the manual-cleanup hint to the user and continue.\n\n! Treat a non-zero exit from `task release:e2e` as a hard refusal to proceed to Phase 4. Surface the diagnostic and ask whether to debug (return to Phase 1) or abort (`quit`).\n\n? **Skip allowed** when the operator has just run `task release:e2e` successfully against the same branch in the past 30 minutes. Note the prior run timestamp in the user-facing summary.\n\n! **`task release:e2e` now also rehearses the npm publish (#1910).** Unless `--skip-npm` is passed (or `npm` is absent from PATH, which soft-skips), the rehearsal runs `npm publish --dry-run --access public` for all four `@deftai/directive*` packages against the throwaway clone in dependency order (types -> core -> content -> cli), after `pnpm install` + `pnpm -w run build` and a version-alignment pass. This catches a broken `files` allowlist, a version-drift bug, or a dependency-order error BEFORE the real `v*` tag fires the publish workflow -- without touching the real registry. The install+build exceeds the <90s fast budget, so pass `task release:e2e -- --skip-npm` when you only need the GitHub-pipeline shape check.\n\n! **Tag -> npm coupling + version invariant (#1910).** A `v<version>` tag is now a TWO-channel action: the GitHub release (this skill's pipeline) AND `.github/workflows/npm-publish.yml`, which runs in a SEPARATE workflow that does NOT block the GitHub release. The npm workflow derives the published version from the tag (`${GITHUB_REF_NAME#v}`); this skill owns the version chosen in Phase 1. These MUST stay consistent -- the tag you cut IS the npm version that ships; there is no separate npm version bump. A red npm job on a green GitHub release means the npm channel did not ship (see Phase 7).\n\n## Phase 4 — Production draft\n\n! Invoke `task release -- <version>` (NO `--dry-run`, NO `--skip-tag`, NO `--skip-release`). If Phase 1 collected an operator summary, pass `--summary \"<text>\"` so the production cut writes the same blockquote the dry-run previewed.\n\n```\ntask release -- <version> --summary \"<text>\"\n```\n\nPer #716 default-draft hardening, this lands the release as a `--draft` on the real repo. Binaries upload via release.yml CI, but the artifact is NOT yet visible to consumers. The operator-authored summary becomes part of the promoted `CHANGELOG.md [<version>]` section AND the GitHub release body (auto-pickup via `_section_for_version`). The same wording is the canonical source for the Phase 8 Slack `*Summary*:` slot.\n\n! **Maintainer-mode release notes auto-lead with an \"Upgrading from an older version?\" banner (#1413).** When the cut targets the canonical framework repo (`deftai/directive`), `scripts/release.py` Step 12 prepends the banner from the editable template at `.github/release-notes/upgrade-banner.md` to the notes passed to `gh release create` (via `_prepend_upgrade_banner`). The banner points consumers at the canonical `deft-install --yes --upgrade --repo-root . --json` upgrade command and #1411. This is **GitHub-release-body-only** -- it is NEVER injected into `CHANGELOG.md`, so the CHANGELOG section and the release body intentionally differ by this leading block. To change the wording, edit the template file; do not hand-edit the published release body. **Consumer-mode releases (any non-`deftai/directive` repo) are unaffected** -- a downstream project that vendors the release pipeline never inherits deft's upgrade guidance. A missing/unreadable template degrades gracefully (notes ship without the banner; the cut is never blocked).\n\n! **Verify isDraft within 5 seconds; flip immediately if not (#724).** Immediately after `gh release create --draft` returns success, `scripts/release.py` Step 11 polls `gh release view v<version> --json isDraft` up to 5 times at 1-second intervals. If the release exists with `isDraft=false`, the pipeline auto-flips it via `gh release edit v<version> --draft=true` and emits a `WARNING: release landed as public; flipping to draft (defense-in-depth, see #724)` line. This closes the ~90-second public-exposure window observed during the v0.21.0 cut where a manual recovery created a public release before the operator noticed and flipped it. The verify gate is defense in depth even when `--draft` was passed correctly: it catches the case where `gh release create` partially succeeded (release record written, error returned) AND the operator-error variant where an alternate code path sent the release without `--draft`. A release-not-found-within-budget result emits a WARN and does NOT fail the pipeline (release.yml CI may still be processing).\n\n! Wait for `task release` to exit 0 before continuing. A non-zero exit means the pipeline halted partway through; consult Phase 7's `task release:rollback` recovery before retrying.\n\n⊗ Pass `--no-draft` here unless the operator has explicitly opted into direct-publish (e.g. automated security patch). The default-draft contract is the foundation of the safety hardening surface.\n\n⊗ Skip the post-create verify-isDraft gate -- the gate is the only reliable safety net against \"create call exited 0 but the release somehow landed as public\" variants (#724). If `task release` is invoked manually outside the canonical `scripts/release.py` flow, the operator MUST run `gh release view v<version> --json isDraft` followed by `gh release edit --draft=true` on `isDraft=false` BEFORE handing off to Phase 5.\n\n## Phase 5 — Draft review gate (user-only authority)\n\n! After `task release` exits 0, present the draft release for user review.\n\n1. ! Run `gh release view v<version> --json url,name,body,assets,isDraft --repo <owner>/<repo>` and present the output to the user\n2. ! Surface the asset list (size + filename) so the user can verify binaries uploaded correctly\n3. ! Surface the auto-generated release notes (or the CHANGELOG section that was promoted into the release body)\n4. ! Wait for explicit user confirmation:\n - `publish` (or `yes` / `confirmed` / `approve`) → proceed to Phase 6 (Publish branch)\n - `rollback` → proceed to Phase 6 (Rollback branch)\n - `defer` → halt and exit. Surface the draft URL so the operator can return later with `task release:publish` or `task release:rollback`. Do NOT auto-merge; do NOT silently wait\n\n⊗ Bypass the user-only authority gate. Even under time pressure or long-context, the release MUST receive an explicit `publish` / `rollback` / `defer` decision from the user. This mirrors the Phase 5→6 gate in `skills/deft-directive-swarm/SKILL.md`.\n\n## Phase 6 — Publish or rollback\n\n! Branch on the user's Phase 5 decision.\n\n### Publish branch (user said `publish`)\n\n```\ntask release:publish -- <version>\n```\n\nThe companion script flips `--draft=false`, then re-reads the release to verify `isDraft == false` actually flipped. State machine:\n- `draft` found → flip to public; verify; exit 0\n- already `published` → exit 0 no-op (idempotent re-runs are safe)\n- `not-found` → exit 1 (cannot publish a missing release)\n- gh-error → exit 1 with diagnostic\n\n! Wait for `task release:publish` to exit 0 before continuing.\n\n### Rollback branch (user said `rollback`)\n\n```\ntask release:rollback -- <version>\n```\n\nThe state-aware unwind detects the post-release state and applies the matching tiered recovery. Time-windowed download-count guard:\n- release age `< 5 min` → threshold = 0 (rollback safe; nobody noticed yet)\n- release age `5-30 min` → threshold = max(`--allow-low-downloads`, 10) (filters bot fetches)\n- release age `> 30 min` → refuse without `--allow-data-loss`\n\nThree escape hatches (escalating warnings):\n- `--allow-low-downloads N` -- accept up to N downloads\n- `--allow-data-loss` -- accept any count (consumer impact)\n- `--force-strict-0` -- require exactly 0 regardless of release age\n\nRace-condition mitigation: `download_count` is double-read with a 5s sleep between reads; rollback only proceeds if both reads agree below threshold.\n\n! When the guard refuses, surface the recommendation to the user: rollback is risky on a released artifact with non-zero downloads. Prefer the **hot-fix path** (cut the next patch with a withdrawal note in `[Unreleased]/Changed` rather than deleting the broken release).\n\n## Phase 7 — Post-publish verification\n\n! Only enter Phase 7 if Phase 6 took the Publish branch (rollback branch ends here with the unwind log).\n\n1. ! Verify GitHub auto-closed the discrete-task issue(s) referenced via `Closes #N` in the release notes (mirrors `skills/deft-directive-swarm/SKILL.md` Phase 6 Step 2)\n2. ! Run `gh issue view <N> --json state --jq .state` for each closed issue. If any didn't auto-close, manually close with `gh issue close <N> --comment \"Closed by release v<version> (squash auto-close did not trigger)\"` (Layer 1, #167)\n3. ! Verify ROADMAP.md correctness via `task roadmap:render` (the release pipeline already invoked this; Phase 7 is the second-pass sanity check)\n4. ! Verify binaries are downloadable from the public release URL: `gh release view v<version> --json assets --jq '.assets[].url'` and curl one to confirm 200 OK\n5. ! **Verify the npm publish landed (#1910, #1909).** The `v<version>` tag fired `.github/workflows/npm-publish.yml` in parallel with this skill's pipeline. Confirm the job succeeded (`gh run list --workflow=npm-publish.yml --repo <owner>/<repo> --limit 1` shows the tag run as `completed`/`success`) AND that all four packages published at the new version with provenance: for each of `@deftai/directive-types`, `@deftai/directive-core`, `@deftai/directive-content`, and `@deftai/directive`, run `npm view <pkg>@<version> version` (expect it to echo `<version>`) and confirm the provenance attestation on the package's npm page. Surface a missing package or a failed job the same way the closing-keyword / renderer-drift checks above surface failures -- a green GitHub release with a red npm job means the npm channel did NOT ship. (Real-registry verification depends on #1909's credential being provisioned; until then this step verifies the workflow-run status and flags the credential gap.)\n6. ! For any umbrella / staying-OPEN issue (`Refs #N`) referenced in the release notes, run the Layer 3 reopen sweep from `skills/deft-directive-swarm/SKILL.md` Phase 6 Step 1: any protected issue that auto-closed MUST be reopened with a comment citing #701\n\n⊗ Skip the post-publish verification. The closing-keyword false-positive (Layer 1 / Layer 2 / Layer 3) and the incremental-renderer-drift (#641, #614) are exactly the kind of issues that surface only AFTER a release is public.\n\n## Phase 8 — Slack announcement\n\n! Generate the canonical Slack release announcement and present it to the user for copy-paste, re-using the template from `skills/deft-directive-swarm/SKILL.md` Phase 6 Step 5.\n\nThe announcement block MUST include:\n\n```\n:rocket: *deft v<version>* -- <release title>\n\n*Summary*: <one-sentence description of the release scope>\n\n*Key Changes*:\n- <bullet per significant change, 3-5 items max>\n\n*Stats*: 1 release | ~<duration> elapsed | <N> commits since v<previous>\n*Release*: <GitHub release URL>\n```\n\n! Populate version from the freshly-published `gh release view v<version>` output. Populate release title from the CHANGELOG section heading (or the GitHub release title). Summarize key changes from the promoted `[Unreleased]` -> `[<version>]` CHANGELOG section (NOT raw commit messages). Populate stats from `git log v<previous>..v<version> --oneline | wc -l`.\n\n! Populate the `*Summary*:` slot VERBATIM from the operator-authored blockquote at the top of the CHANGELOG `[<version>]` section (the line beginning with `> ` immediately after the `## [<version>] - <date>` heading). The Phase 1 prompt + Phase 4 `--summary` flag exist precisely so this populate step is mechanical -- one canonical narrative authored once at Phase 1, propagated through Phase 4 promotion, and copy-pasted here without re-authoring. If the CHANGELOG section has no blockquote (operator skipped the Phase 1 prompt), generate a one-sentence summary from the `### Added` / `### Changed` bullets and surface to the operator that this is a regenerated narrative (NOT canonical) so they can decide whether to amend the CHANGELOG before publishing.\n\n! Present the block as a code-fenced snippet the user can copy directly. Do NOT post to Slack from inside this skill -- the user owns the actual broadcast.\n\n## Skill Completion\n\n! When Phase 8 completes (or when Phase 5 took the `defer` / `quit` path, or when Phase 6 completed the rollback branch), explicitly confirm skill exit:\n\n```\ndeft-directive-release complete -- exiting skill.\nNext: <one-line guidance>\n```\n\nWhere `<one-line guidance>` is one of:\n- \"release v<version> live -- monitor consumer reports for ~24h before cutting v<next>\"\n- \"release v<version> rolled back -- the underlying defect needs a hot-fix in the next CHANGELOG entry\"\n- \"release deferred -- resume by running `task release:publish -- <version>` (or `task release:rollback -- <version>`) when ready\"\n\n⊗ Exit silently without confirming completion or providing next-step guidance.\n\n## Anti-Patterns\n\n- ⊗ Run `task release` without a Phase 2 dry-run preview -- the dry-run is the only safe place to catch a bad version, malformed CHANGELOG, or wrong base branch\n- ⊗ Skip Phase 3 (e2e rehearsal) on the assumption that \"the dry-run is enough\" -- the e2e harness catches gh-CLI auth issues, repo permission gaps, and pipeline-shape regressions that the dry-run cannot detect\n- ⊗ Pass `--no-draft` to `task release` without explicit operator opt-in -- the default-draft contract is the foundation of the safety hardening surface\n- ⊗ Auto-publish a draft without the Phase 5 user-only authority gate -- even under time pressure or long-context, the release MUST receive an explicit `publish` / `rollback` / `defer` decision\n- ⊗ Run `task release:rollback` against a release that has > 30 minutes of consumer-driven downloads without first weighing the hot-fix path -- a withdrawal note in the next patch is almost always less disruptive than deleting a public artifact\n- ⊗ Use `--allow-data-loss` without first reading the script docstring's hot-fix-path recommendation -- the flag is an explicit acknowledgment of consumer impact, not a default\n- ⊗ Skip the Phase 7 Layer 3 reopen sweep -- protected umbrellas can auto-close on a release-merge squash even when the release notes use `Refs #N` only\n- ⊗ Post the Phase 8 Slack announcement directly from this skill -- the user owns the broadcast; the skill only generates the template\n- ⊗ Hardcode `master` as the base branch -- delegate to the configured base branch from `task release --base-branch <branch>`\n- ⊗ Skip the post-create verify-isDraft gate (#724) -- a successful `gh release create` exit code does NOT prove the release actually landed in draft state; the 5-second poll-and-flip gate in `scripts/release.py` Step 11 is the only safety net against operator-error variants and partial-success races, and any manual recovery path that bypasses `scripts/release.py` MUST run `gh release view --json isDraft` followed by `gh release edit --draft=true` on `isDraft=false` before handing off to Phase 5\n- ⊗ Manually rewrite the Phase 8 Slack `*Summary*:` line to deviate from the CHANGELOG `[<version>]` blockquote -- the canonical narrative is authored ONCE at Phase 1 via `--summary` and propagates verbatim across all three audiences (CHANGELOG / GitHub release body / Slack). Per-audience hand-edits create documentation drift that the deterministic `--summary` flow is designed to prevent. If the operator wants Slack-specific tone, fold it into the canonical Phase 1 wording before passing `--summary`, OR amend the CHANGELOG blockquote BEFORE Phase 8 so all three surfaces stay aligned\n- ⊗ Export `DEFT_ALLOW_DEFAULT_BRANCH_COMMIT=1` for the entire release session or wrap `task release` / `task ci:local` in it (#1553) -- the env var is process-wide and leaks into nested tests and temporary repos, producing false preflight failures. Prefer `task policy:allow-direct-commits -- --confirm` and restore with `task policy:enforce-branches` after the cut\n",
|
|
180
|
+
"body": "# Deft Directive Release\n\nStructured 8-phase workflow for cutting a v0.X.Y release of the deft framework. Operationalizes the `task release` / `task release:publish` / `task release:rollback` / `task release:e2e` surface introduced in #716 (safety hardening of #74).\n\nLegend (from RFC2119): !=MUST, ~=SHOULD, ≉=SHOULD NOT, ⊗=MUST NOT, ?=MAY.\n\n**See also**: [deft-directive-swarm](../deft-directive-swarm/SKILL.md) Phase 6 Step 5 (Slack announcement template re-used by Phase 8 below) | [deft-directive-review-cycle](../deft-directive-review-cycle/SKILL.md) (user-gate pattern) | [deft-directive-refinement](../deft-directive-refinement/SKILL.md) (conversational phased flow).\n\n## Platform Requirements\n\n! GitHub as the SCM platform; the **GitHub CLI (`gh`)** must be installed and authenticated. The full pipeline plus the rehearsal target (`task release:e2e`) all dispatch through `gh`.\n\n## Branch-Protection Policy Guard\n\n! Before any Phase 1 state mutation, run the skill-level branch-policy guard documented in `scripts/policy.py` / `scripts/preflight_branch.py` (#746 / #747). Releases run on the configured base branch (default `master`), so the operator MUST be on the explicit-opt-in side of the policy before the pipeline starts writing files.\n\n**Preferred path — typed direct-commit policy opt-out (#1553).** For a release session on the default branch, prefer the audited typed flag over the emergency env-var bypass:\n\n```\ntask policy:allow-direct-commits -- --confirm\n```\n\nThis writes `plan.policy.allowDirectCommitsToMaster = true` on `vbrief/PROJECT-DEFINITION.vbrief.json` with a capability-cost disclosure. After the release completes (or if the session aborts), restore enforcement:\n\n```\ntask policy:enforce-branches\n```\n\n**Branch-guard probe (either path).** Regardless of which opt-out path you chose, confirm the guard passes before Phase 1 mutates state:\n\n```\nuv run python scripts/preflight_branch.py --project-root . --quiet || exit 1\n```\n\nor invoke `task verify:branch`. This is the canonical surface that surfaces the policy state to the operator before the pipeline starts writing files. The release pipeline's other safety surfaces (the dirty-tree guard, base-branch check, `task ci:local` gate) remain independent of this check.\n\n**Emergency env-var bypass — narrow scope only (#1553).** `DEFT_ALLOW_DEFAULT_BRANCH_COMMIT=1` is process-wide: every child process, nested test, and temporary repository spawned from the same shell inherits it. During the v0.43.0 release attempt, wrapping the entire `task release` invocation in this env var let the bypass leak into the Step 5 `task ci:local` preflight, which caused `TestWriteConsumerGitHooks_VendoredCommitBlocked_RealGit` to fail because the vendored test repo allowed a direct `master` commit the test expected the hook to block.\n\n- ! Prefer `task policy:allow-direct-commits -- --confirm` for release sessions instead of exporting `DEFT_ALLOW_DEFAULT_BRANCH_COMMIT=1` for the whole shell.\n- ⊗ Wrap `task release`, `task ci:local`, or `task check` in `DEFT_ALLOW_DEFAULT_BRANCH_COMMIT=1` -- the env var is inherited by every subprocess and can produce false preflight failures before any release mutation.\n- ? If the env-var path is unavoidable, scope it to a **single** branch-guard probe only (e.g. `DEFT_ALLOW_DEFAULT_BRANCH_COMMIT=1 task verify:branch`) and do NOT export it for the release session. The release pipeline itself passes the bypass only in scoped subprocess `env=` for its authorised commit/tag/push mutations (#867); operators MUST NOT mirror that pattern at the shell level.\n\nThe release pipeline's Step 9/10/11 git mutations carry the bypass in subprocess `env=` only (`scripts/release.py::_release_subprocess_env`, #867) so the parent shell stays clean. Operator-side env-var exports defeat that isolation.\n\n## Deterministic Questions Contract\n\n! Every numbered-menu prompt rendered in this skill (Phase 1 version-bump magnitude check, Phase 2 dry-run review `yes`/`back`/`quit`, Phase 5 optional `defer`/`rollback`/`Discuss` (happy path auto-publishes after draft QA)) MUST follow [`../../contracts/deterministic-questions.md`](../../contracts/deterministic-questions.md): the final two numbered options MUST be `Discuss` and `Back`, in that order. Existing `back`/`quit` options remain valid; this contract simply adds `Discuss` as a peer alongside `Back`. The Discuss-pause semantic is documented verbatim in the contract -- implicit resumption is forbidden.\n\n## When to Use\n\n- User says \"release\", \"cut release\", \"v0.X.Y\", \"publish release\", \"ship a release\"\n- The framework's `[Unreleased]` CHANGELOG section is non-empty and the operator wants to cut a tagged release\n- A previous release rehearsal succeeded and the operator is ready for the production cut\n\n## Phase 1 — Pre-flight\n\n! Validate the local + remote state before any irreversible action.\n\n1. ! Verify the operator is on the configured base branch (default `master`) and the working tree is clean\n2. ! Confirm the next version number (`X.Y.Z`) with the user. Major / minor / patch decision flows from the `[Unreleased]` content (breaking change → major; new feature → minor; fix-only → patch)\n3. ! Inspect `[Unreleased]` content vs the proposed version bump. If a breaking change appears in `### Changed` / `### Removed` but only a patch is proposed, surface the mismatch and ask the user to choose\n4. ! Verify `task ci:local` passes locally (or `task check` as the graceful-degradation fallback per `tasks/release.yml` line 9-10). The `task release` script will refuse to proceed otherwise -- but Phase 1 catches it earlier\n5. ! Verify `gh auth status` reports authenticated (`task release` will refuse otherwise)\n6. ! **Run `task reconcile:issues -- --apply-lifecycle-fixes` to clear any closed-issue / non-completed-folder vBRIEFs before invoking `task release`** (#734). The release pipeline carries the deterministic gate at Step 3 (`scripts/release.py::check_vbrief_lifecycle_sync`, refuses with `EXIT_VIOLATION` on any Section (c) mismatch), but Phase 1 is the operator's first-line defence -- running the apply-mode flag here is the canonical clean path; `--allow-vbrief-drift` on the pipeline exists only as the explicit-acknowledgment escape hatch (analogous to `--allow-dirty`). The recurrence record is the v0.21.0 cut, which surfaced 13 stranded vBRIEFs (8 cycle-relevant + 5 historical residue) post-publish; the gate now blocks that drift before any irreversible action\n7. ! **Verify the proposed `v<version>` tag is not already in use locally, on origin, or as a published GitHub release** (#784). The release pipeline carries the deterministic gate at Step 4 (`scripts/release.py::check_tag_available`, refuses with `EXIT_VIOLATION` before any state mutation -- CHANGELOG promotion, ROADMAP refresh, build, commit), but Phase 1 is the operator's first-line defence. Quickly probe with `git tag -l v<version>` (local), `git ls-remote --tags origin refs/tags/v<version>` (remote), and `gh release view v<version> --repo <owner>/<repo>` (release-only, where `gh release view` exits 0 only when the release exists). The recurrence record is the v0.22.0 → v0.23.0 release attempt on 2026-05-01: the operator typed `0.22.0` (the prior release from 12 hours earlier) and the legacy pipeline ran 8 steps before failing at `git tag` -- leaving a wrong-version local commit + `dist/deft-0.22.0.zip` orphan + manual `git reset --hard` recovery. The new pre-flight gate blocks that mode before any irreversible action\n8. ! **Verify the npm credential path is configured before cutting the tag** (#1910, #1909). A `v*` tag now auto-triggers `.github/workflows/npm-publish.yml`, which publishes the four `@deftai/directive*` packages with `npm publish --provenance`. Confirm the publish path can authenticate: either the `NPM_TOKEN` repo secret is present (`gh secret list --repo <owner>/<repo>` shows `NPM_TOKEN`) OR an npm OIDC trusted publisher is configured for the `@deftai/directive*` packages. If neither is in place, WARN loudly that the tag will fire a publish job that fails (red X on the tag, no packages) -- the operator may still proceed for a GitHub-only release, but the npm channel will not land until #1909's credential is provisioned. Cross-reference #1909.\n9. ! **Disclose npm irrevocability before any tag push (#1972, #2002).** A `v<version>` tag push is the **real npm publish gate** -- NOT Phase 5 or `task release:publish`. Tag push fires `.github/workflows/npm-publish.yml` in a separate workflow that is NOT draft-gated; npm packages ship immediately and **cannot be retracted** (`npm unpublish` is forbidden). Recovery is forward-only: deprecate, dist-tag, or ship a patch. The operator's explicit `yes` in Phase 2 (dry-run) and the decision to invoke `task release` in Phase 4 are the last human gates before npm goes live. Phase 5 only controls GitHub release visibility (draft → public); it does NOT gate npm.\n10. ~ Ask the operator for an optional one-line release **summary** (recommended 80-160 chars; can be skipped). The summary is the canonical narrative for THIS release across three audiences: (a) injected as a Markdown blockquote at the top of the promoted `CHANGELOG.md [<version>]` section, (b) auto-flowed into the GitHub release body via the existing `_section_for_version` pickup, and (c) populated VERBATIM into the Phase 8 Slack `*Summary*:` slot. Capture the wording once here; do NOT regenerate per-audience downstream\n\n⊗ Skip the version-bump magnitude check -- a patch release that ships breaking changes is the kind of regression that Repair Authority [AXIOM] (#709) is designed to prevent.\n\n⊗ Skip the vBRIEF-lifecycle-sync check (#734); the gate exists because operators consistently forget the manual `task scope:complete` move step. The v0.21.0 cut surfaced 13 stranded vBRIEFs (8 cycle-relevant + 5 historical residue) post-publish as the recurrence record this gate prevents. If `task release` reports `[3/13] Pre-flight vBRIEF lifecycle sync... FAIL (<count> mismatches; run task reconcile:issues -- --apply-lifecycle-fixes to fix)`, the canonical recovery is the apply-mode invocation -- `--allow-vbrief-drift` is reserved for cases where the operator has explicitly reviewed the drift and chosen to defer the lifecycle reconcile to the next refinement pass (e.g. an emergency hot-fix release).\n\n⊗ Skip the tag-availability check (#784); the gate exists because the legacy 12-step pipeline only invoked `git tag` at Step 9, after Steps 1-8 had already mutated state (CHANGELOG promoted, ROADMAP refreshed, dist built, release commit made locally). A duplicate-tag failure at Step 9 stranded the operator with an unpushed wrong-version commit + orphaned `dist/deft-<wrong>.zip` artifact + manual `git reset --hard` recovery (forbidden by AGENTS.md SCM rules without explicit permission). The recurrence record is the v0.22.0 → v0.23.0 release attempt on 2026-05-01. If `task release` reports `[4/13] Pre-flight tag availability... FAIL (<surface> tag v<version> already exists ...)`, the canonical recovery is to choose a different version (the most likely cause is operator typo of a prior release).\n\n⊗ Hand-write a different one-line narrative for each of the three downstream surfaces (CHANGELOG / GitHub release / Slack) -- that drift is exactly the gap the `--summary` flag is designed to close. If the operator insists on per-audience tone, populate the canonical `--summary` ONCE here and document the deviation in the Phase 8 anti-pattern.\n\n## Phase 2 — Dry-run review\n\n! Invoke `task release -- <version> --dry-run --skip-tag --skip-release` and present the plan to the user. If Phase 1 collected an operator summary, also pass `--summary \"<text>\"` so the dry-run preview reflects the canonical narrative the operator just authored.\n\n```\ntask release -- <version> --dry-run --skip-tag --skip-release --summary \"<text>\"\n```\n\nThe dry-run prints `[N/13] <step>... DRYRUN (would <action>)` for every pipeline step (Step 13 is the post-create verify-isDraft gate added by #724; Step 4 is the tag-availability pre-flight gate added by #784). Step 6 (CHANGELOG promotion) surfaces whether a summary was supplied (truncated to ~60 chars in the preview) so the operator can validate the wording before any file is written. Capture the output and present it to the user, then wait for explicit confirmation before continuing.\n\n! Wait for explicit user confirmation: `yes` / `back` / `quit`. Remind the operator that Phase 4's tag push will irrevocably publish npm (#1972) -- this `yes` is the last safe abort before that channel opens.\n- `yes` (or `confirmed` / `approve`) → proceed to Phase 3\n- `back` → return to Phase 1 for re-validation (e.g. user wants to amend the version or `[Unreleased]` content)\n- `quit` → abort the workflow cleanly; no state changes\n\n⊗ Skip the dry-run preview. The dry-run is the operator's last opportunity to catch a bad version number, malformed CHANGELOG, or wrong base branch before the pipeline starts writing files.\n\n## Phase 3 — E2E sanity\n\n! Invoke `task release:e2e` against an auto-created+destroyed temp repo to verify the full pipeline shape works end-to-end before touching the real repo.\n\n```\ntask release:e2e\n```\n\nThe harness provisions `deftai/deftai-release-test-<ts>-<uuid6>`, runs the smoke-test rehearsal, and destroys the temp repo in a `try/finally` clause. Cleanup runs even if the rehearsal fails. If `gh repo delete` fails, surface the manual-cleanup hint to the user and continue.\n\n! Treat a non-zero exit from `task release:e2e` as a hard refusal to proceed to Phase 4. Surface the diagnostic and ask whether to debug (return to Phase 1) or abort (`quit`).\n\n? **Skip allowed** when the operator has just run `task release:e2e` successfully against the same branch in the past 30 minutes. Note the prior run timestamp in the user-facing summary.\n\n! **`task release:e2e` now also rehearses the npm publish (#1910).** Unless `--skip-npm` is passed (or `npm` is absent from PATH, which soft-skips), the rehearsal runs `npm publish --dry-run --access public` for all four `@deftai/directive*` packages against the throwaway clone in dependency order (types -> core -> content -> cli), after `pnpm install` + `pnpm -w run build` and a version-alignment pass. This catches a broken `files` allowlist, a version-drift bug, or a dependency-order error BEFORE the real `v*` tag fires the publish workflow -- without touching the real registry. The install+build exceeds the <90s fast budget, so pass `task release:e2e -- --skip-npm` when you only need the GitHub-pipeline shape check.\n\n! **Tag -> npm coupling + irrevocability (#1910, #1972, #2002).** A `v<version>` tag is a TWO-channel action: the GitHub release (this skill's pipeline) AND `.github/workflows/npm-publish.yml`, which runs in a SEPARATE workflow that does NOT block the GitHub release and is NOT draft-gated. The npm workflow derives the published version from the tag (`${GITHUB_REF_NAME#v}`); this skill owns the version chosen in Phase 1. These MUST stay consistent -- the tag you cut IS the npm version that ships; there is no separate npm version bump. **npm publish is irrevocable** (#1972): once the tag fires, packages are live on npm and cannot be unpulled; `task release:rollback` does NOT retract npm (forward-only recovery). A red npm job on a green GitHub release means the npm channel did not ship (verify in Phase 5/7).\n\n## Phase 4 — Production draft\n\n! **Last human gate before npm (#1972, #2002).** Immediately before invoking `task release`, re-state that the tag push in this step will irrevocably publish all four `@deftai/directive*` packages to npm via `.github/workflows/npm-publish.yml`. There is no undo on npm; only forward recovery (deprecate / dist-tag / patch). Proceed only when the operator explicitly confirms.\n\n! Invoke `task release -- <version>` (NO `--dry-run`, NO `--skip-tag`, NO `--skip-release`). If Phase 1 collected an operator summary, pass `--summary \"<text>\"` so the production cut writes the same blockquote the dry-run previewed.\n\n```\ntask release -- <version> --summary \"<text>\"\n```\n\nPer #716 default-draft hardening, this lands the release as a `--draft` on the real repo. Binaries upload via release.yml CI, but the artifact is NOT yet visible to consumers. The operator-authored summary becomes part of the promoted `CHANGELOG.md [<version>]` section AND the GitHub release body (auto-pickup via `_section_for_version`). The same wording is the canonical source for the Phase 8 Slack `*Summary*:` slot.\n\n! **Maintainer-mode release notes auto-lead with an \"Upgrading from an older version?\" banner (#1413).** When the cut targets the canonical framework repo (`deftai/directive`), `scripts/release.py` Step 12 prepends the banner from the editable template at `.github/release-notes/upgrade-banner.md` to the notes passed to `gh release create` (via `_prepend_upgrade_banner`). The banner points consumers at the canonical `deft-install --yes --upgrade --repo-root . --json` upgrade command and #1411. This is **GitHub-release-body-only** -- it is NEVER injected into `CHANGELOG.md`, so the CHANGELOG section and the release body intentionally differ by this leading block. To change the wording, edit the template file; do not hand-edit the published release body. **Consumer-mode releases (any non-`deftai/directive` repo) are unaffected** -- a downstream project that vendors the release pipeline never inherits deft's upgrade guidance. A missing/unreadable template degrades gracefully (notes ship without the banner; the cut is never blocked).\n\n! **Verify isDraft within 5 seconds; flip immediately if not (#724).** Immediately after `gh release create --draft` returns success, `scripts/release.py` Step 11 polls `gh release view v<version> --json isDraft` up to 5 times at 1-second intervals. If the release exists with `isDraft=false`, the pipeline auto-flips it via `gh release edit v<version> --draft=true` and emits a `WARNING: release landed as public; flipping to draft (defense-in-depth, see #724)` line. This closes the ~90-second public-exposure window observed during the v0.21.0 cut where a manual recovery created a public release before the operator noticed and flipped it. The verify gate is defense in depth even when `--draft` was passed correctly: it catches the case where `gh release create` partially succeeded (release record written, error returned) AND the operator-error variant where an alternate code path sent the release without `--draft`. A release-not-found-within-budget result emits a WARN and does NOT fail the pipeline (release.yml CI may still be processing).\n\n! Wait for `task release` to exit 0 before continuing. A non-zero exit means the pipeline halted partway through; consult Phase 7's `task release:rollback` recovery before retrying.\n\n⊗ Pass `--no-draft` here unless the operator has explicitly opted into direct-publish (e.g. automated security patch). The default-draft contract is the foundation of the safety hardening surface.\n\n⊗ Skip the post-create verify-isDraft gate -- the gate is the only reliable safety net against \"create call exited 0 but the release somehow landed as public\" variants (#724). If `task release` is invoked manually outside the canonical `scripts/release.py` flow, the operator MUST run `gh release view v<version> --json isDraft` followed by `gh release edit --draft=true` on `isDraft=false` BEFORE handing off to Phase 5.\n\n## Phase 5 — GitHub draft QA (optional; NOT the npm authority gate)\n\n! After `task release` exits 0, QA the **GitHub draft release** only. npm packages typically **already shipped** when the tag push in Phase 4 fired `.github/workflows/npm-publish.yml` (#1972, #2002). Phase 5 is NOT a \"user-only authority before going live\" gate for the release as a whole -- it is optional draft QA for GitHub assets, notes, and binaries.\n\n1. ! **Verify npm publish status FIRST (in parallel with draft inspection).** Run `gh run list --workflow=npm-publish.yml --repo <owner>/<repo> --limit 5` and confirm the tag run for `v<version>` is `completed`/`success`. If npm failed, surface immediately -- the GitHub draft QA is secondary to a red npm channel.\n2. ! Run `gh release view v<version> --json url,name,body,assets,isDraft --repo <owner>/<repo>` and present the output to the user\n3. ! Surface the asset list (size + filename) so the user can verify binaries uploaded correctly\n4. ! Surface the auto-generated release notes (or the CHANGELOG section that was promoted into the release body)\n\n### Happy path (default when npm succeeded and draft assets look correct)\n\n! When the npm workflow succeeded AND draft assets/notes pass inspection, **auto-proceed to Phase 6 Publish branch** -- run `task release:publish -- <version>` without a redundant human publish prompt (#2002). npm already shipped at tag push; waiting for a separate `publish` confirmation does not protect the npm channel.\n\n? **Operator override:** if the operator wants to hold the GitHub release in draft (e.g. embargo, last-minute notes edit), they MAY say `defer` before auto-publish runs.\n\n### Exception paths (operator-initiated)\n\n- `rollback` → proceed to Phase 6 (Rollback branch). **Reminder:** rollback unwinds the GitHub release only; npm packages already published at tag push are NOT retracted (#1972).\n- `defer` → halt and exit. Surface the draft URL so the operator can return later with `task release:publish -- <version>` or `task release:rollback -- <version>`.\n\n⊗ Treat Phase 5 as the npm publish-authority gate -- npm ships at tag push (Phase 4), not at `task release:publish`. A human `publish` prompt here is redundant when npm already succeeded and only delays flipping the GitHub draft to public.\n⊗ Skip npm workflow verification in Phase 5 and defer it entirely to post-publish Phase 7 -- npm status MUST be checked before or in parallel with the GitHub publish flip.\n\n## Phase 6 — Publish or rollback\n\n! Branch on the Phase 5 outcome. The happy path auto-enters the Publish branch when npm succeeded and draft QA passed (#2002).\n\n### Publish branch (happy path auto-run, or resumed after `defer`)\n\n```\ntask release:publish -- <version>\n```\n\nThe companion script flips `--draft=false`, then re-reads the release to verify `isDraft == false` actually flipped. State machine:\n- `draft` found → flip to public; verify; exit 0\n- already `published` → exit 0 no-op (idempotent re-runs are safe)\n- `not-found` → exit 1 (cannot publish a missing release)\n- gh-error → exit 1 with diagnostic\n\n! Wait for `task release:publish` to exit 0 before continuing. On the happy path this runs immediately after Phase 5 draft QA without a separate human publish prompt.\n\n### Rollback branch (user said `rollback`)\n\n```\ntask release:rollback -- <version>\n```\n\nThe state-aware unwind detects the post-release state and applies the matching tiered recovery. Time-windowed download-count guard:\n- release age `< 5 min` → threshold = 0 (rollback safe; nobody noticed yet)\n- release age `5-30 min` → threshold = max(`--allow-low-downloads`, 10) (filters bot fetches)\n- release age `> 30 min` → refuse without `--allow-data-loss`\n\nThree escape hatches (escalating warnings):\n- `--allow-low-downloads N` -- accept up to N downloads\n- `--allow-data-loss` -- accept any count (consumer impact)\n- `--force-strict-0` -- require exactly 0 regardless of release age\n\nRace-condition mitigation: `download_count` is double-read with a 5s sleep between reads; rollback only proceeds if both reads agree below threshold.\n\n! When the guard refuses, surface the recommendation to the user: rollback is risky on a released artifact with non-zero downloads. Prefer the **hot-fix path** (cut the next patch with a withdrawal note in `[Unreleased]/Changed` rather than deleting the broken release).\n\n! **`task release:rollback` does NOT retract npm (#1972, #2002).** Rollback unwinds GitHub release state (draft/public, tag, assets) only. npm packages published at tag push remain on the registry irrevocably. Recovery is forward-only: deprecate the bad version, move a dist-tag, or ship a patch release.\n\n## Phase 7 — Post-publish verification\n\n! Only enter Phase 7 if Phase 6 took the Publish branch (rollback branch ends here with the unwind log).\n\n1. ! **Re-verify npm publish landed (#1910, #1909, #2002).** Phase 5 checked workflow status before the GitHub publish flip; Phase 7 confirms registry truth AFTER `task release:publish`. For each of `@deftai/directive-types`, `@deftai/directive-core`, `@deftai/directive-content`, and `@deftai/directive`, run `npm view <pkg>@<version> version` (expect `<version>`) and confirm provenance on the npm page. A green GitHub release with missing npm packages means consumers cannot `npm i -g @deftai/directive@<version>` -- escalate immediately. (Real-registry verification depends on #1909's credential; until then verify workflow-run status and flag credential gaps.)\n2. ! Verify GitHub auto-closed the discrete-task issue(s) referenced via `Closes #N` in the release notes (mirrors `skills/deft-directive-swarm/SKILL.md` Phase 6 Step 2)\n3. ! Run `gh issue view <N> --json state --jq .state` for each closed issue. If any didn't auto-close, manually close with `gh issue close <N> --comment \"Closed by release v<version> (squash auto-close did not trigger)\"` (Layer 1, #167)\n4. ! Verify ROADMAP.md correctness via `task roadmap:render` (the release pipeline already invoked this; Phase 7 is the second-pass sanity check)\n5. ! Verify binaries are downloadable from the public release URL: `gh release view v<version> --json assets --jq '.assets[].url'` and curl one to confirm 200 OK\n6. ! For any umbrella / staying-OPEN issue (`Refs #N`) referenced in the release notes, run the Layer 3 reopen sweep from `skills/deft-directive-swarm/SKILL.md` Phase 6 Step 1: any protected issue that auto-closed MUST be reopened with a comment citing #701\n\n⊗ Skip the post-publish verification. The closing-keyword false-positive (Layer 1 / Layer 2 / Layer 3) and the incremental-renderer-drift (#641, #614) are exactly the kind of issues that surface only AFTER a release is public.\n\n## Phase 8 — Slack announcement\n\n! Generate the canonical Slack release announcement and present it to the user for copy-paste, re-using the template from `skills/deft-directive-swarm/SKILL.md` Phase 6 Step 5.\n\nThe announcement block MUST include:\n\n```\n:rocket: *deft v<version>* -- <release title>\n\n*Summary*: <one-sentence description of the release scope>\n\n*Key Changes*:\n- <bullet per significant change, 3-5 items max>\n\n*Stats*: 1 release | ~<duration> elapsed | <N> commits since v<previous>\n*Release*: <GitHub release URL>\n```\n\n! Populate version from the freshly-published `gh release view v<version>` output. Populate release title from the CHANGELOG section heading (or the GitHub release title). Summarize key changes from the promoted `[Unreleased]` -> `[<version>]` CHANGELOG section (NOT raw commit messages). Populate stats from `git log v<previous>..v<version> --oneline | wc -l`.\n\n! Populate the `*Summary*:` slot VERBATIM from the operator-authored blockquote at the top of the CHANGELOG `[<version>]` section (the line beginning with `> ` immediately after the `## [<version>] - <date>` heading). The Phase 1 prompt + Phase 4 `--summary` flag exist precisely so this populate step is mechanical -- one canonical narrative authored once at Phase 1, propagated through Phase 4 promotion, and copy-pasted here without re-authoring. If the CHANGELOG section has no blockquote (operator skipped the Phase 1 prompt), generate a one-sentence summary from the `### Added` / `### Changed` bullets and surface to the operator that this is a regenerated narrative (NOT canonical) so they can decide whether to amend the CHANGELOG before publishing.\n\n! Present the block as a code-fenced snippet the user can copy directly. Do NOT post to Slack from inside this skill -- the user owns the actual broadcast.\n\n## Skill Completion\n\n! When Phase 8 completes (or when Phase 5 took the `defer` / `quit` path, or when Phase 6 completed the rollback branch), explicitly confirm skill exit:\n\n```\ndeft-directive-release complete -- exiting skill.\nNext: <one-line guidance>\n```\n\nWhere `<one-line guidance>` is one of:\n- \"release v<version> live -- monitor consumer reports for ~24h before cutting v<next>\"\n- \"release v<version> rolled back -- the underlying defect needs a hot-fix in the next CHANGELOG entry\"\n- \"release deferred -- resume by running `task release:publish -- <version>` (GitHub only; npm already shipped at tag push) or `task release:rollback -- <version>` (GitHub unwind only; npm is forward-recovery) when ready\"\n\n⊗ Exit silently without confirming completion or providing next-step guidance.\n\n## Anti-Patterns\n\n- ⊗ Run `task release` without a Phase 2 dry-run preview -- the dry-run is the only safe place to catch a bad version, malformed CHANGELOG, or wrong base branch\n- ⊗ Skip Phase 3 (e2e rehearsal) on the assumption that \"the dry-run is enough\" -- the e2e harness catches gh-CLI auth issues, repo permission gaps, and pipeline-shape regressions that the dry-run cannot detect\n- ⊗ Pass `--no-draft` to `task release` without explicit operator opt-in -- the default-draft contract is the foundation of the safety hardening surface\n- ⊗ Treat Phase 5 as the npm authority gate or require a redundant human `publish` prompt when npm already succeeded -- npm ships at tag push (#1972); Phase 5 is GitHub draft QA only\n- ⊗ Expect `task release:rollback` to retract npm packages -- rollback is GitHub-only; npm recovery is forward-only (deprecate / dist-tag / patch)\n- ⊗ Run `task release:rollback` against a release that has > 30 minutes of consumer-driven downloads without first weighing the hot-fix path -- a withdrawal note in the next patch is almost always less disruptive than deleting a public artifact\n- ⊗ Use `--allow-data-loss` without first reading the script docstring's hot-fix-path recommendation -- the flag is an explicit acknowledgment of consumer impact, not a default\n- ⊗ Skip the Phase 7 Layer 3 reopen sweep -- protected umbrellas can auto-close on a release-merge squash even when the release notes use `Refs #N` only\n- ⊗ Post the Phase 8 Slack announcement directly from this skill -- the user owns the broadcast; the skill only generates the template\n- ⊗ Hardcode `master` as the base branch -- delegate to the configured base branch from `task release --base-branch <branch>`\n- ⊗ Skip the post-create verify-isDraft gate (#724) -- a successful `gh release create` exit code does NOT prove the release actually landed in draft state; the 5-second poll-and-flip gate in `scripts/release.py` Step 11 is the only safety net against operator-error variants and partial-success races, and any manual recovery path that bypasses `scripts/release.py` MUST run `gh release view --json isDraft` followed by `gh release edit --draft=true` on `isDraft=false` before handing off to Phase 5\n- ⊗ Manually rewrite the Phase 8 Slack `*Summary*:` line to deviate from the CHANGELOG `[<version>]` blockquote -- the canonical narrative is authored ONCE at Phase 1 via `--summary` and propagates verbatim across all three audiences (CHANGELOG / GitHub release body / Slack). Per-audience hand-edits create documentation drift that the deterministic `--summary` flow is designed to prevent. If the operator wants Slack-specific tone, fold it into the canonical Phase 1 wording before passing `--summary`, OR amend the CHANGELOG blockquote BEFORE Phase 8 so all three surfaces stay aligned\n- ⊗ Export `DEFT_ALLOW_DEFAULT_BRANCH_COMMIT=1` for the entire release session or wrap `task release` / `task ci:local` in it (#1553) -- the env var is process-wide and leaks into nested tests and temporary repos, producing false preflight failures. Prefer `task policy:allow-direct-commits -- --confirm` and restore with `task policy:enforce-branches` after the cut\n",
|
|
181
181
|
"frontmatter_extra": null
|
|
182
182
|
},
|
|
183
183
|
{
|
package/scripts/doctor.py
CHANGED
|
@@ -131,6 +131,14 @@ VERSION = _resolve_version()
|
|
|
131
131
|
# UV url constant (the _check_uv_available helper remains in run for other callers)
|
|
132
132
|
UV_INSTALL_URL = "https://docs.astral.sh/uv/"
|
|
133
133
|
|
|
134
|
+
# Post-freeze npm canonical path (#1997 / #2003 / #1912).
|
|
135
|
+
CANONICAL_UPGRADE_COMMAND = "npm i -g @deftai/directive@latest"
|
|
136
|
+
NPM_PACKAGE_NAME = "@deftai/directive"
|
|
137
|
+
UPGRADING_DOC_URL = "https://github.com/deftai/directive/blob/master/content/UPGRADING.md"
|
|
138
|
+
GO_BRIDGE_RELEASES_URL = "https://github.com/deftai/directive/releases"
|
|
139
|
+
NPM_MANAGED_SENTINEL_KEY = "managed_by"
|
|
140
|
+
NPM_MANAGED_SENTINEL_VALUE = "npm"
|
|
141
|
+
|
|
134
142
|
# --- Install-integrity checks (ported from retired framework_doctor.py #1336) ---
|
|
135
143
|
# Symbols (EXIT_*, run_checks, main, CheckResult, DoctorResult + 4 checks + impl)
|
|
136
144
|
# are inserted below in small batches. Once complete, _run_install_integrity_checks
|
|
@@ -765,6 +773,207 @@ def _check_install_path_consistency(project_root: Path, install_root: str | None
|
|
|
765
773
|
)
|
|
766
774
|
|
|
767
775
|
|
|
776
|
+
# ---------------------------------------------------------------------------
|
|
777
|
+
# Legacy-layout + canonical-vendored npm signpost (#1912 / #1997)
|
|
778
|
+
# ---------------------------------------------------------------------------
|
|
779
|
+
|
|
780
|
+
|
|
781
|
+
def _gitmodules_references_framework(text: str) -> bool:
|
|
782
|
+
normalized = text.replace("\r\n", "\n").lower()
|
|
783
|
+
if "deftai/directive" in normalized:
|
|
784
|
+
return True
|
|
785
|
+
return bool(re.search(r"(^|\n)\s*path\s*=\s*\.?deft(/|\s|$)", normalized))
|
|
786
|
+
|
|
787
|
+
|
|
788
|
+
def _detect_legacy_layout(project_root: Path) -> tuple[bool, str | None, str, list[str]]:
|
|
789
|
+
"""Mirror packages/core/src/init-deposit/legacy-detect.ts heuristics."""
|
|
790
|
+
if (project_root / ".deft" / "core").is_dir():
|
|
791
|
+
return False, None, "", []
|
|
792
|
+
orphan = project_root / ".deft" / "VERSION"
|
|
793
|
+
if orphan.is_file():
|
|
794
|
+
return (
|
|
795
|
+
True,
|
|
796
|
+
"orphan-deft-version",
|
|
797
|
+
"Found an orphan .deft/VERSION manifest with no .deft/core/ directory -- "
|
|
798
|
+
"this is a pre-.deft/core/ layout the npm CLI does not migrate.",
|
|
799
|
+
[".deft/VERSION"],
|
|
800
|
+
)
|
|
801
|
+
deft_dir = project_root / "deft"
|
|
802
|
+
deft_markers = [
|
|
803
|
+
deft_dir / "VERSION",
|
|
804
|
+
deft_dir / "main.md",
|
|
805
|
+
deft_dir / "Taskfile.yml",
|
|
806
|
+
]
|
|
807
|
+
deft_is_framework = deft_dir.is_dir() and (
|
|
808
|
+
any(p.is_file() for p in deft_markers) or (deft_dir / "skills").is_dir()
|
|
809
|
+
)
|
|
810
|
+
if deft_is_framework:
|
|
811
|
+
if (deft_dir / ".git").exists():
|
|
812
|
+
return (
|
|
813
|
+
True,
|
|
814
|
+
"git-clone-or-submodule",
|
|
815
|
+
"Found a deft/ framework directory backed by its own .git (clone or "
|
|
816
|
+
"git submodule) -- the npm CLI does not migrate a clone/submodule deposit.",
|
|
817
|
+
["deft/", "deft/.git"],
|
|
818
|
+
)
|
|
819
|
+
return (
|
|
820
|
+
True,
|
|
821
|
+
"legacy-deft-prefixed",
|
|
822
|
+
"Found a legacy deft/-prefixed framework install -- the canonical layout "
|
|
823
|
+
"is .deft/core/. The npm CLI does not migrate the deft/ -> .deft/core/ move.",
|
|
824
|
+
["deft/"],
|
|
825
|
+
)
|
|
826
|
+
gitmodules = project_root / ".gitmodules"
|
|
827
|
+
if gitmodules.is_file():
|
|
828
|
+
text = _read_text_safe(gitmodules)
|
|
829
|
+
if text is not None and _gitmodules_references_framework(text):
|
|
830
|
+
return (
|
|
831
|
+
True,
|
|
832
|
+
"git-clone-or-submodule",
|
|
833
|
+
"Found a .gitmodules entry referencing the Deft framework -- a submodule "
|
|
834
|
+
"deposit the npm CLI does not migrate.",
|
|
835
|
+
[".gitmodules"],
|
|
836
|
+
)
|
|
837
|
+
agents_text = _read_text_safe(project_root / "AGENTS.md")
|
|
838
|
+
if agents_text is not None:
|
|
839
|
+
install_root = _parse_install_root_from_agents_md(agents_text)
|
|
840
|
+
if install_root == "deft":
|
|
841
|
+
return (
|
|
842
|
+
True,
|
|
843
|
+
"legacy-deft-prefixed",
|
|
844
|
+
"AGENTS.md declares the legacy deft/ install root -- the canonical layout "
|
|
845
|
+
"is .deft/core/. The npm CLI does not migrate the deft/ -> .deft/core/ move.",
|
|
846
|
+
["AGENTS.md (install root: deft)"],
|
|
847
|
+
)
|
|
848
|
+
if (
|
|
849
|
+
"<!-- deft:managed-section" in agents_text
|
|
850
|
+
and _extract_managed_section(agents_text) is None
|
|
851
|
+
):
|
|
852
|
+
return (
|
|
853
|
+
True,
|
|
854
|
+
"pre-v0.27-sentinel-agents-md",
|
|
855
|
+
"AGENTS.md carries a pre-v0.27 sentinel-only managed-section (no v2/v3 "
|
|
856
|
+
"managed-section markers) -- run the Go bridge to migrate before the npm CLI.",
|
|
857
|
+
["AGENTS.md (sentinel-only managed-section)"],
|
|
858
|
+
)
|
|
859
|
+
return False, None, "", []
|
|
860
|
+
|
|
861
|
+
|
|
862
|
+
def _legacy_layout_signpost_line(kind: str | None, detail: str) -> str:
|
|
863
|
+
return (
|
|
864
|
+
f"Legacy Deft layout detected ({kind or 'unknown'}): {detail} "
|
|
865
|
+
"Run the frozen Go bridge installer to migrate to .deft/core/, then use the npm "
|
|
866
|
+
f"CLI (`npx @deftai/directive update`). See {UPGRADING_DOC_URL} "
|
|
867
|
+
f"(frozen bridge: {GO_BRIDGE_RELEASES_URL})."
|
|
868
|
+
)
|
|
869
|
+
|
|
870
|
+
|
|
871
|
+
def _check_legacy_layout(project_root: Path) -> CheckResult:
|
|
872
|
+
legacy, kind, detail, evidence = _detect_legacy_layout(project_root)
|
|
873
|
+
if not legacy:
|
|
874
|
+
return CheckResult(
|
|
875
|
+
name="legacy-layout",
|
|
876
|
+
status="skip",
|
|
877
|
+
detail="No legacy Deft layout detected (canonical .deft/core/ or greenfield).",
|
|
878
|
+
data={"legacy_layout": False},
|
|
879
|
+
)
|
|
880
|
+
signpost = _legacy_layout_signpost_line(kind, detail)
|
|
881
|
+
return CheckResult(
|
|
882
|
+
name="legacy-layout",
|
|
883
|
+
status="fail",
|
|
884
|
+
detail=signpost,
|
|
885
|
+
data={
|
|
886
|
+
"legacy_layout": True,
|
|
887
|
+
"legacy_layout_kind": kind,
|
|
888
|
+
"evidence": evidence,
|
|
889
|
+
"upgrading_doc_url": UPGRADING_DOC_URL,
|
|
890
|
+
"go_bridge_releases_url": GO_BRIDGE_RELEASES_URL,
|
|
891
|
+
},
|
|
892
|
+
)
|
|
893
|
+
|
|
894
|
+
|
|
895
|
+
def _detect_canonical_vendored_manifest(project_root: Path) -> Path | None:
|
|
896
|
+
canonical = project_root / ".deft" / "core" / "VERSION"
|
|
897
|
+
located = _locate_manifest(project_root, ".deft/core")
|
|
898
|
+
return located if located == canonical else None
|
|
899
|
+
|
|
900
|
+
|
|
901
|
+
def _is_npm_managed(manifest: dict) -> bool:
|
|
902
|
+
return manifest.get(NPM_MANAGED_SENTINEL_KEY) == NPM_MANAGED_SENTINEL_VALUE
|
|
903
|
+
|
|
904
|
+
|
|
905
|
+
def _check_canonical_vendored_npm_signpost(project_root: Path) -> CheckResult:
|
|
906
|
+
manifest_path = _detect_canonical_vendored_manifest(project_root)
|
|
907
|
+
if manifest_path is None:
|
|
908
|
+
return CheckResult(
|
|
909
|
+
name="canonical-vendored-npm-signpost",
|
|
910
|
+
status="skip",
|
|
911
|
+
detail="No canonical-vendored .deft/core/ deposit (nothing to signpost).",
|
|
912
|
+
data={"canonical_vendored": False},
|
|
913
|
+
)
|
|
914
|
+
text = _read_text_safe(manifest_path)
|
|
915
|
+
if text is None:
|
|
916
|
+
return CheckResult(
|
|
917
|
+
name="canonical-vendored-npm-signpost",
|
|
918
|
+
status="skip",
|
|
919
|
+
detail="Canonical-vendored manifest unreadable.",
|
|
920
|
+
data={"canonical_vendored": True},
|
|
921
|
+
)
|
|
922
|
+
manifest = _parse_manifest(text)
|
|
923
|
+
if _is_npm_managed(manifest):
|
|
924
|
+
return CheckResult(
|
|
925
|
+
name="canonical-vendored-npm-signpost",
|
|
926
|
+
status="skip",
|
|
927
|
+
detail="Deposit is already npm-managed (hybrid).",
|
|
928
|
+
data={"canonical_vendored": True, "npm_managed": True},
|
|
929
|
+
)
|
|
930
|
+
detail = (
|
|
931
|
+
"Canonical-vendored install (.deft/core/) is not yet npm-managed. "
|
|
932
|
+
"Post-freeze upgrades run via npm: install the engine with "
|
|
933
|
+
f"`{CANONICAL_UPGRADE_COMMAND}`, then run `directive migrate` "
|
|
934
|
+
f"to stamp provenance. See {UPGRADING_DOC_URL}."
|
|
935
|
+
)
|
|
936
|
+
return CheckResult(
|
|
937
|
+
name="canonical-vendored-npm-signpost",
|
|
938
|
+
status="fail",
|
|
939
|
+
detail=detail,
|
|
940
|
+
data={
|
|
941
|
+
"canonical_vendored": True,
|
|
942
|
+
"npm_managed": False,
|
|
943
|
+
"manifest_path": str(manifest_path),
|
|
944
|
+
"sentinel_key": NPM_MANAGED_SENTINEL_KEY,
|
|
945
|
+
"sentinel_value": NPM_MANAGED_SENTINEL_VALUE,
|
|
946
|
+
"upgrading_doc_url": UPGRADING_DOC_URL,
|
|
947
|
+
},
|
|
948
|
+
)
|
|
949
|
+
|
|
950
|
+
|
|
951
|
+
def _run_local_signpost_checks(
|
|
952
|
+
project_root: Path,
|
|
953
|
+
*,
|
|
954
|
+
emit_warn,
|
|
955
|
+
add_finding,
|
|
956
|
+
) -> None:
|
|
957
|
+
"""Lightweight local signposts on throttle-skip (#1997)."""
|
|
958
|
+
if _running_inside_deft_repo(project_root):
|
|
959
|
+
return
|
|
960
|
+
for check in (
|
|
961
|
+
_check_legacy_layout(project_root),
|
|
962
|
+
_check_canonical_vendored_npm_signpost(project_root),
|
|
963
|
+
):
|
|
964
|
+
if check.status == "skip":
|
|
965
|
+
continue
|
|
966
|
+
if check.status == "fail":
|
|
967
|
+
emit_warn(check.detail)
|
|
968
|
+
add_finding(
|
|
969
|
+
"warning",
|
|
970
|
+
check.detail,
|
|
971
|
+
check=check.name,
|
|
972
|
+
status=check.status,
|
|
973
|
+
data=check.data,
|
|
974
|
+
)
|
|
975
|
+
|
|
976
|
+
|
|
768
977
|
# ---------------------------------------------------------------------------
|
|
769
978
|
# Top-level driver (ported) -- provides run_checks for tests + internal use
|
|
770
979
|
# ---------------------------------------------------------------------------
|
|
@@ -820,6 +1029,8 @@ def _run_checks_impl(project_root: Path) -> DoctorResult:
|
|
|
820
1029
|
# Still attempt the manifest agreement check (it can run without
|
|
821
1030
|
# AGENTS.md for the greenfield case).
|
|
822
1031
|
checks.append(_check_manifest_agreement(project_root, None))
|
|
1032
|
+
checks.append(_check_legacy_layout(project_root))
|
|
1033
|
+
checks.append(_check_canonical_vendored_npm_signpost(project_root))
|
|
823
1034
|
return DoctorResult(
|
|
824
1035
|
project_root=str(project_root),
|
|
825
1036
|
install_root=None,
|
|
@@ -832,6 +1043,8 @@ def _run_checks_impl(project_root: Path) -> DoctorResult:
|
|
|
832
1043
|
checks.append(_check_skill_paths_resolve(project_root, agents_md_text))
|
|
833
1044
|
checks.append(_check_manifest_agreement(project_root, install_root))
|
|
834
1045
|
checks.append(_check_install_path_consistency(project_root, install_root))
|
|
1046
|
+
checks.append(_check_legacy_layout(project_root))
|
|
1047
|
+
checks.append(_check_canonical_vendored_npm_signpost(project_root))
|
|
835
1048
|
|
|
836
1049
|
return DoctorResult(
|
|
837
1050
|
project_root=str(project_root),
|
|
@@ -846,7 +1059,10 @@ def _derive_exit_code(checks: list[CheckResult], errors: list[str]) -> int:
|
|
|
846
1059
|
"""Three-state exit code from check results + errors."""
|
|
847
1060
|
if errors or any(c.status == "error" for c in checks):
|
|
848
1061
|
return EXIT_CONFIG_ERROR
|
|
849
|
-
if any(
|
|
1062
|
+
if any(
|
|
1063
|
+
c.status == "fail" and c.name != "canonical-vendored-npm-signpost"
|
|
1064
|
+
for c in checks
|
|
1065
|
+
):
|
|
850
1066
|
return EXIT_DRIFT
|
|
851
1067
|
return EXIT_CLEAN
|
|
852
1068
|
|
|
@@ -1310,8 +1526,21 @@ def _render_doctor_status_line(decision) -> str:
|
|
|
1310
1526
|
)
|
|
1311
1527
|
|
|
1312
1528
|
|
|
1313
|
-
def _emit_doctor_throttle_skip(decision, *, json_mode: bool) -> int:
|
|
1529
|
+
def _emit_doctor_throttle_skip(decision, *, json_mode: bool, project_root: Path) -> int:
|
|
1314
1530
|
"""Print the throttle-skip surface and return the gated exit code (#1308)."""
|
|
1531
|
+
signpost_findings: list[dict] = []
|
|
1532
|
+
|
|
1533
|
+
def _add_signpost(severity: str, message: str, **extras: object) -> None:
|
|
1534
|
+
entry: dict = {"severity": severity, "message": message}
|
|
1535
|
+
entry.update(extras)
|
|
1536
|
+
signpost_findings.append(entry)
|
|
1537
|
+
|
|
1538
|
+
_run_local_signpost_checks(
|
|
1539
|
+
project_root,
|
|
1540
|
+
emit_warn=warn if not json_mode else (lambda _m: None),
|
|
1541
|
+
add_finding=_add_signpost,
|
|
1542
|
+
)
|
|
1543
|
+
|
|
1315
1544
|
hint = (
|
|
1316
1545
|
"run `deft doctor --full` to re-probe or address findings"
|
|
1317
1546
|
if decision.dirty
|
|
@@ -1327,9 +1556,17 @@ def _emit_doctor_throttle_skip(decision, *, json_mode: bool) -> int:
|
|
|
1327
1556
|
"next_eligible_at": _format_iso_z(decision.next_eligible_at),
|
|
1328
1557
|
"hint": hint,
|
|
1329
1558
|
}
|
|
1330
|
-
|
|
1559
|
+
if signpost_findings:
|
|
1560
|
+
payload["signpost_findings"] = signpost_findings
|
|
1561
|
+
print(json.dumps(payload, sort_keys=True, ensure_ascii=False))
|
|
1331
1562
|
else:
|
|
1332
1563
|
print(_render_doctor_status_line(decision))
|
|
1564
|
+
signpost_warnings = sum(1 for f in signpost_findings if f.get("severity") == "warning")
|
|
1565
|
+
if signpost_warnings:
|
|
1566
|
+
warn(
|
|
1567
|
+
f"Signpost advisory: {signpost_warnings} local layout / npm-migration "
|
|
1568
|
+
"note(s) above (throttle-skipped full probe)."
|
|
1569
|
+
)
|
|
1333
1570
|
return 1 if decision.dirty else 0
|
|
1334
1571
|
|
|
1335
1572
|
|
|
@@ -1404,6 +1641,17 @@ def _run_install_integrity_checks(
|
|
|
1404
1641
|
if status == "skip":
|
|
1405
1642
|
emit_info(f"{name}: skip -- {detail}")
|
|
1406
1643
|
continue
|
|
1644
|
+
if name in ("legacy-layout", "canonical-vendored-npm-signpost") and status == "fail":
|
|
1645
|
+
emit_warn(f"{name}: {detail}")
|
|
1646
|
+
add_finding(
|
|
1647
|
+
"warning",
|
|
1648
|
+
detail or f"{name} {status}",
|
|
1649
|
+
check=f"install-integrity:{name}",
|
|
1650
|
+
install_check=name,
|
|
1651
|
+
status=status,
|
|
1652
|
+
data=entry.get("data", {}),
|
|
1653
|
+
)
|
|
1654
|
+
continue
|
|
1407
1655
|
if status == "error":
|
|
1408
1656
|
emit_error(f"{name}: error -- {detail}")
|
|
1409
1657
|
else:
|
|
@@ -1488,6 +1736,43 @@ def _run_agents_md_freshness_check(
|
|
|
1488
1736
|
add_finding("warning", message, check=check_name, status=state)
|
|
1489
1737
|
|
|
1490
1738
|
|
|
1739
|
+
def _parse_semver(version: str) -> tuple[int, ...]:
|
|
1740
|
+
normalized = version.strip().lstrip("vV")
|
|
1741
|
+
parts: list[int] = []
|
|
1742
|
+
for segment in normalized.split("."):
|
|
1743
|
+
try:
|
|
1744
|
+
parts.append(int(segment.split("-")[0]))
|
|
1745
|
+
except ValueError:
|
|
1746
|
+
break
|
|
1747
|
+
return tuple(parts) if parts else (0,)
|
|
1748
|
+
|
|
1749
|
+
|
|
1750
|
+
def _semver_less_than(left: str, right: str) -> bool:
|
|
1751
|
+
return _parse_semver(left) < _parse_semver(right)
|
|
1752
|
+
|
|
1753
|
+
|
|
1754
|
+
def _npm_view_version() -> tuple[bool, str]:
|
|
1755
|
+
try:
|
|
1756
|
+
proc = subprocess.run(
|
|
1757
|
+
["npm", "view", NPM_PACKAGE_NAME, "version"],
|
|
1758
|
+
capture_output=True,
|
|
1759
|
+
text=True,
|
|
1760
|
+
timeout=15,
|
|
1761
|
+
)
|
|
1762
|
+
except Exception: # noqa: BLE001
|
|
1763
|
+
return False, ""
|
|
1764
|
+
version = (proc.stdout or "").strip().splitlines()[0].strip() if proc.stdout else ""
|
|
1765
|
+
return proc.returncode == 0 and bool(version), version
|
|
1766
|
+
|
|
1767
|
+
|
|
1768
|
+
def _manifest_version(ref: str, tag: str) -> str:
|
|
1769
|
+
candidate = (tag or ref).strip().replace("refs/tags/", "")
|
|
1770
|
+
normalized = candidate.lstrip("vV")
|
|
1771
|
+
if not re.match(r"^\d+(?:\.\d+)*", normalized):
|
|
1772
|
+
return ""
|
|
1773
|
+
return normalized
|
|
1774
|
+
|
|
1775
|
+
|
|
1491
1776
|
def _run_payload_staleness_check(
|
|
1492
1777
|
project_root: Path,
|
|
1493
1778
|
*,
|
|
@@ -1495,17 +1780,8 @@ def _run_payload_staleness_check(
|
|
|
1495
1780
|
emit_info,
|
|
1496
1781
|
add_finding,
|
|
1497
1782
|
) -> None:
|
|
1498
|
-
"""#1339
|
|
1499
|
-
manifest-recorded ref/sha. Reads the canonical <deftDir>/VERSION manifest
|
|
1500
|
-
(single source of truth per #1062), resolves the corresponding remote SHA
|
|
1501
|
-
via git ls-remote, and surfaces the canonical headless upgrade command
|
|
1502
|
-
`deft-install --yes --upgrade --repo-root . --json` (#1409) when the shas
|
|
1503
|
-
diverge. Skips gracefully inside the deft repo or when git / network /
|
|
1504
|
-
manifest unavailable (non-fatal, best-effort).
|
|
1505
|
-
"""
|
|
1783
|
+
"""#1339 / #2003 / #2004: payload staleness with npm canonical upgrade path."""
|
|
1506
1784
|
check_name = "payload-staleness"
|
|
1507
|
-
# Self-contained "inside deft repo" probe (avoids dependency on private
|
|
1508
|
-
# _running_inside_deft_repo helper that may be scoped inside cmd_doctor).
|
|
1509
1785
|
try:
|
|
1510
1786
|
agents = project_root / "AGENTS.md"
|
|
1511
1787
|
is_deft = agents.exists() and (
|
|
@@ -1516,40 +1792,28 @@ def _run_payload_staleness_check(
|
|
|
1516
1792
|
emit_info(f"{check_name}: skip -- running inside deft framework repo")
|
|
1517
1793
|
add_finding(
|
|
1518
1794
|
"skip", "inside framework repo (no install manifest)",
|
|
1519
|
-
check=check_name, status="skip",
|
|
1795
|
+
check=check_name, status="skip", reason="not-applicable",
|
|
1520
1796
|
)
|
|
1521
1797
|
return
|
|
1522
1798
|
except Exception:
|
|
1523
1799
|
pass
|
|
1524
1800
|
|
|
1525
|
-
# Locate a plausible manifest. Prefer the one next to the scripts/doctor.py
|
|
1526
|
-
# we are running from (when invoked via the installed layout); fall back to
|
|
1527
|
-
# common canonical/legacy locations under project_root.
|
|
1528
1801
|
manifest_path = None
|
|
1529
1802
|
try:
|
|
1530
|
-
# When doctor.py lives at <deftDir>/scripts/doctor.py the manifest is at <deftDir>/VERSION
|
|
1531
1803
|
candidate = get_script_dir().parent / "VERSION"
|
|
1532
1804
|
if candidate.exists():
|
|
1533
1805
|
manifest_path = candidate
|
|
1534
1806
|
except Exception:
|
|
1535
1807
|
pass
|
|
1536
1808
|
if manifest_path is None:
|
|
1537
|
-
# #1427: probe canonical-first via the shared helper so a
|
|
1538
|
-
# webinstaller-vendored ``.deft/VERSION`` manifest is found too
|
|
1539
|
-
# (the prior list probed only ``.deft/core/VERSION`` and legacy
|
|
1540
|
-
# ``deft/VERSION``).
|
|
1541
1809
|
manifest_path = _locate_manifest(project_root, None)
|
|
1542
1810
|
if manifest_path is None:
|
|
1543
|
-
# Legacy bare marker -- not a full manifest, but the last-resort
|
|
1544
|
-
# provenance source for a pre-v0.28 install. Kept out of
|
|
1545
|
-
# ``_locate_manifest`` because that helper returns VERSION-manifest
|
|
1546
|
-
# paths only.
|
|
1547
1811
|
legacy_marker = project_root / ".deft-version"
|
|
1548
1812
|
if legacy_marker.exists():
|
|
1549
1813
|
manifest_path = legacy_marker
|
|
1550
1814
|
if manifest_path is None or not manifest_path.exists():
|
|
1551
1815
|
emit_info(f"{check_name}: skip -- no install manifest found (pre-v0.28 or legacy state)")
|
|
1552
|
-
add_finding("skip", "no manifest", check=check_name, status="skip")
|
|
1816
|
+
add_finding("skip", "no manifest", check=check_name, status="skip", reason="not-applicable")
|
|
1553
1817
|
return
|
|
1554
1818
|
|
|
1555
1819
|
try:
|
|
@@ -1557,106 +1821,134 @@ def _run_payload_staleness_check(
|
|
|
1557
1821
|
manifest = _parse_install_manifest(text)
|
|
1558
1822
|
except Exception as exc: # noqa: BLE001
|
|
1559
1823
|
emit_info(f"{check_name}: skip -- could not read manifest: {exc}")
|
|
1560
|
-
add_finding(
|
|
1824
|
+
add_finding(
|
|
1825
|
+
"skip",
|
|
1826
|
+
f"manifest unreadable: {exc}",
|
|
1827
|
+
check=check_name,
|
|
1828
|
+
status="skip",
|
|
1829
|
+
reason="not-applicable",
|
|
1830
|
+
)
|
|
1561
1831
|
return
|
|
1562
1832
|
|
|
1563
1833
|
installed_sha = manifest.get("sha", "").strip()
|
|
1564
|
-
# Greptile P1 on #1384: do NOT fall back to "HEAD" when ref/tag are
|
|
1565
|
-
# absent. `git ls-remote origin HEAD` returns the current remote
|
|
1566
|
-
# default-branch tip, which almost certainly differs from the locally
|
|
1567
|
-
# installed sha for development builds without a ref/tag pinned, and
|
|
1568
|
-
# the check would then emit a permanent false-stale warning. Skip
|
|
1569
|
-
# cleanly when the manifest does not declare a ref/tag.
|
|
1570
1834
|
ref = (manifest.get("ref") or manifest.get("tag") or "").strip()
|
|
1835
|
+
tag = (manifest.get("tag") or "").strip()
|
|
1571
1836
|
if not installed_sha:
|
|
1572
1837
|
emit_info(f"{check_name}: skip -- manifest has no sha (incomplete provenance)")
|
|
1573
|
-
add_finding(
|
|
1838
|
+
add_finding(
|
|
1839
|
+
"skip",
|
|
1840
|
+
"no sha in manifest",
|
|
1841
|
+
check=check_name,
|
|
1842
|
+
status="skip",
|
|
1843
|
+
reason="not-applicable",
|
|
1844
|
+
)
|
|
1574
1845
|
return
|
|
1575
1846
|
if not ref:
|
|
1576
1847
|
emit_info(
|
|
1577
1848
|
f"{check_name}: skip -- manifest has no ref or tag (cannot resolve remote sha)"
|
|
1578
1849
|
)
|
|
1579
|
-
add_finding(
|
|
1850
|
+
add_finding(
|
|
1851
|
+
"skip",
|
|
1852
|
+
"no ref/tag in manifest",
|
|
1853
|
+
check=check_name,
|
|
1854
|
+
status="skip",
|
|
1855
|
+
reason="not-applicable",
|
|
1856
|
+
)
|
|
1580
1857
|
return
|
|
1581
1858
|
|
|
1582
|
-
|
|
1583
|
-
|
|
1859
|
+
deft_dir = manifest_path.parent
|
|
1860
|
+
ls_remote_ok = False
|
|
1861
|
+
remote_sha = ""
|
|
1584
1862
|
try:
|
|
1585
|
-
# Determine the deft dir from manifest location (parent of VERSION)
|
|
1586
|
-
deft_dir = manifest_path.parent
|
|
1587
|
-
# ls-remote origin <ref> (works for branches and tags)
|
|
1588
1863
|
proc = subprocess.run(
|
|
1589
1864
|
["git", "-C", str(deft_dir), "ls-remote", "origin", ref],
|
|
1590
1865
|
capture_output=True,
|
|
1591
1866
|
text=True,
|
|
1592
1867
|
timeout=15,
|
|
1593
1868
|
)
|
|
1594
|
-
|
|
1595
|
-
|
|
1596
|
-
|
|
1597
|
-
|
|
1598
|
-
|
|
1599
|
-
|
|
1600
|
-
|
|
1601
|
-
|
|
1602
|
-
|
|
1603
|
-
|
|
1604
|
-
|
|
1605
|
-
|
|
1606
|
-
|
|
1607
|
-
|
|
1608
|
-
|
|
1609
|
-
|
|
1610
|
-
|
|
1611
|
-
if refname.endswith("^{}"):
|
|
1612
|
-
peeled_sha = parts[0]
|
|
1613
|
-
elif not remote_sha:
|
|
1869
|
+
ls_remote_ok = proc.returncode == 0
|
|
1870
|
+
if ls_remote_ok:
|
|
1871
|
+
peeled_sha = ""
|
|
1872
|
+
for line in proc.stdout.splitlines():
|
|
1873
|
+
parts = line.strip().split()
|
|
1874
|
+
if len(parts) >= 2:
|
|
1875
|
+
refname = parts[1]
|
|
1876
|
+
if refname.endswith("^{}"):
|
|
1877
|
+
peeled_sha = parts[0]
|
|
1878
|
+
elif not remote_sha:
|
|
1879
|
+
remote_sha = parts[0]
|
|
1880
|
+
if peeled_sha:
|
|
1881
|
+
remote_sha = peeled_sha
|
|
1882
|
+
elif not remote_sha:
|
|
1883
|
+
first_line = next((ln for ln in proc.stdout.splitlines() if ln.strip()), "")
|
|
1884
|
+
parts = first_line.strip().split()
|
|
1885
|
+
if parts:
|
|
1614
1886
|
remote_sha = parts[0]
|
|
1615
|
-
|
|
1616
|
-
|
|
1617
|
-
|
|
1618
|
-
|
|
1619
|
-
|
|
1620
|
-
|
|
1621
|
-
if parts:
|
|
1622
|
-
remote_sha = parts[0]
|
|
1623
|
-
if not remote_sha:
|
|
1624
|
-
emit_info(f"{check_name}: skip -- ls-remote produced no sha")
|
|
1625
|
-
add_finding("skip", "no remote sha", check=check_name, status="skip")
|
|
1887
|
+
except Exception: # noqa: BLE001
|
|
1888
|
+
ls_remote_ok = False
|
|
1889
|
+
|
|
1890
|
+
if ls_remote_ok and remote_sha:
|
|
1891
|
+
if installed_sha == remote_sha:
|
|
1892
|
+
emit_info(f"{check_name}: current (sha matches remote)")
|
|
1626
1893
|
return
|
|
1627
|
-
|
|
1628
|
-
|
|
1629
|
-
|
|
1894
|
+
msg = (
|
|
1895
|
+
f"Framework payload is stale (installed sha {installed_sha[:8]}... "
|
|
1896
|
+
f"behind remote {remote_sha[:8]}... for ref '{ref}'). "
|
|
1897
|
+
f"Recommendation: run `{CANONICAL_UPGRADE_COMMAND}` from any shell with Node ≥ 20."
|
|
1898
|
+
)
|
|
1899
|
+
emit_warn(msg)
|
|
1900
|
+
add_finding(
|
|
1901
|
+
"warning",
|
|
1902
|
+
msg,
|
|
1903
|
+
check=check_name,
|
|
1904
|
+
status="stale",
|
|
1905
|
+
installed_sha=installed_sha,
|
|
1906
|
+
remote_sha=remote_sha,
|
|
1907
|
+
ref=ref,
|
|
1908
|
+
suggestion=CANONICAL_UPGRADE_COMMAND,
|
|
1909
|
+
resolver="git-ls-remote",
|
|
1910
|
+
)
|
|
1630
1911
|
return
|
|
1631
1912
|
|
|
1632
|
-
|
|
1633
|
-
|
|
1634
|
-
|
|
1913
|
+
npm_ok, npm_version = _npm_view_version()
|
|
1914
|
+
installed_version = _manifest_version(ref, tag)
|
|
1915
|
+
if npm_ok and installed_version:
|
|
1916
|
+
if _semver_less_than(installed_version, npm_version):
|
|
1917
|
+
msg = (
|
|
1918
|
+
f"Framework payload is stale (installed v{installed_version} "
|
|
1919
|
+
f"behind npm registry v{npm_version} for ref '{ref}'). "
|
|
1920
|
+
f"Recommendation: run `{CANONICAL_UPGRADE_COMMAND}` from any shell with Node ≥ 20."
|
|
1921
|
+
)
|
|
1922
|
+
emit_warn(msg)
|
|
1923
|
+
add_finding(
|
|
1924
|
+
"warning",
|
|
1925
|
+
msg,
|
|
1926
|
+
check=check_name,
|
|
1927
|
+
status="stale",
|
|
1928
|
+
installed_version=installed_version,
|
|
1929
|
+
remote_version=npm_version,
|
|
1930
|
+
ref=ref,
|
|
1931
|
+
suggestion=CANONICAL_UPGRADE_COMMAND,
|
|
1932
|
+
resolver="npm-view",
|
|
1933
|
+
)
|
|
1934
|
+
return
|
|
1935
|
+
emit_info(f"{check_name}: current (version matches npm registry)")
|
|
1635
1936
|
return
|
|
1636
1937
|
|
|
1637
|
-
|
|
1638
|
-
|
|
1639
|
-
|
|
1640
|
-
|
|
1641
|
-
msg = (
|
|
1642
|
-
f"Framework payload is stale (installed sha {installed_sha[:8]}... "
|
|
1643
|
-
f"behind remote {remote_sha[:8]}... for ref '{ref}'). "
|
|
1644
|
-
f"Recommendation: run the canonical headless upgrader "
|
|
1645
|
-
f"`{recommended_command}` from your project root to pull the latest "
|
|
1646
|
-
f"payload (drop `--json` for human-readable output). On an installer "
|
|
1647
|
-
f"binary predating the headless flags, download the latest deft-install "
|
|
1648
|
-
f"from GitHub Releases first."
|
|
1938
|
+
reason = (
|
|
1939
|
+
"ls-remote produced no sha and npm registry fallback unavailable"
|
|
1940
|
+
if ls_remote_ok
|
|
1941
|
+
else "could not reach remote (git ls-remote / npm view both unavailable)"
|
|
1649
1942
|
)
|
|
1650
|
-
|
|
1943
|
+
emit_info(f"{check_name}: skip -- {reason}")
|
|
1944
|
+
unverified_msg = f"payload currency UNVERIFIED — {reason}"
|
|
1945
|
+
emit_warn(unverified_msg)
|
|
1651
1946
|
add_finding(
|
|
1652
1947
|
"warning",
|
|
1653
|
-
|
|
1948
|
+
unverified_msg,
|
|
1654
1949
|
check=check_name,
|
|
1655
|
-
status="
|
|
1656
|
-
|
|
1657
|
-
remote_sha=remote_sha,
|
|
1658
|
-
ref=ref,
|
|
1659
|
-
suggestion=recommended_command,
|
|
1950
|
+
status="unverified",
|
|
1951
|
+
reason=reason,
|
|
1660
1952
|
)
|
|
1661
1953
|
|
|
1662
1954
|
|
|
@@ -1791,7 +2083,9 @@ def cmd_doctor(args: list[str]):
|
|
|
1791
2083
|
if not full_mode:
|
|
1792
2084
|
decision = _evaluate_doctor_throttle(project_root)
|
|
1793
2085
|
if decision is not None and decision.skip:
|
|
1794
|
-
return _emit_doctor_throttle_skip(
|
|
2086
|
+
return _emit_doctor_throttle_skip(
|
|
2087
|
+
decision, json_mode=json_mode, project_root=project_root
|
|
2088
|
+
)
|
|
1795
2089
|
|
|
1796
2090
|
# Findings are the single source of truth for the summary, the
|
|
1797
2091
|
# JSON payload, and the exit code (#1303 review #1 / #4). Replaces
|
|
@@ -2132,7 +2426,7 @@ def cmd_doctor(args: list[str]):
|
|
|
2132
2426
|
},
|
|
2133
2427
|
"project_root": str(project_root),
|
|
2134
2428
|
}
|
|
2135
|
-
print(json.dumps(payload, sort_keys=True))
|
|
2429
|
+
print(json.dumps(payload, sort_keys=True, ensure_ascii=False))
|
|
2136
2430
|
return exit_code
|
|
2137
2431
|
|
|
2138
2432
|
print()
|
|
@@ -2242,7 +2536,7 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
2242
2536
|
project_root = Path(args.project_root).resolve()
|
|
2243
2537
|
result = _run_checks_impl(project_root)
|
|
2244
2538
|
if args.json:
|
|
2245
|
-
print(json.dumps(result.to_dict(), sort_keys=True))
|
|
2539
|
+
print(json.dumps(result.to_dict(), sort_keys=True, ensure_ascii=False))
|
|
2246
2540
|
else:
|
|
2247
2541
|
if not (args.quiet and result.exit_code == EXIT_CLEAN):
|
|
2248
2542
|
print(_format_text_report(result))
|
|
@@ -4,9 +4,10 @@ description: >-
|
|
|
4
4
|
Cut a v0.X.Y release of the deft framework safely. Use when the user says
|
|
5
5
|
"release", "cut release", "v0.X.Y", or "publish release" -- to walk an
|
|
6
6
|
8-phase workflow that pre-flights, runs an end-to-end rehearsal against a
|
|
7
|
-
temp repo, lands a draft
|
|
8
|
-
|
|
9
|
-
Slack announcement
|
|
7
|
+
temp repo, lands a draft GitHub release (npm ships irrevocably at tag push),
|
|
8
|
+
optionally QA's draft assets, then publishes the GitHub release or rolls
|
|
9
|
+
back. Re-uses the deft-directive-swarm Phase 6 Step 5 Slack announcement
|
|
10
|
+
template.
|
|
10
11
|
---
|
|
11
12
|
<!-- AUTO-GENERATED by task packs:render -- DO NOT EDIT MANUALLY -->
|
|
12
13
|
<!-- Purpose: rendered skill -->
|
|
@@ -60,7 +61,7 @@ The release pipeline's Step 9/10/11 git mutations carry the bypass in subprocess
|
|
|
60
61
|
|
|
61
62
|
## Deterministic Questions Contract
|
|
62
63
|
|
|
63
|
-
! Every numbered-menu prompt rendered in this skill (Phase 1 version-bump magnitude check, Phase 2 dry-run review `yes`/`back`/`quit`, Phase 5 `
|
|
64
|
+
! Every numbered-menu prompt rendered in this skill (Phase 1 version-bump magnitude check, Phase 2 dry-run review `yes`/`back`/`quit`, Phase 5 optional `defer`/`rollback`/`Discuss` (happy path auto-publishes after draft QA)) MUST follow [`../../contracts/deterministic-questions.md`](../../contracts/deterministic-questions.md): the final two numbered options MUST be `Discuss` and `Back`, in that order. Existing `back`/`quit` options remain valid; this contract simply adds `Discuss` as a peer alongside `Back`. The Discuss-pause semantic is documented verbatim in the contract -- implicit resumption is forbidden.
|
|
64
65
|
|
|
65
66
|
## When to Use
|
|
66
67
|
|
|
@@ -80,7 +81,8 @@ The release pipeline's Step 9/10/11 git mutations carry the bypass in subprocess
|
|
|
80
81
|
6. ! **Run `task reconcile:issues -- --apply-lifecycle-fixes` to clear any closed-issue / non-completed-folder vBRIEFs before invoking `task release`** (#734). The release pipeline carries the deterministic gate at Step 3 (`scripts/release.py::check_vbrief_lifecycle_sync`, refuses with `EXIT_VIOLATION` on any Section (c) mismatch), but Phase 1 is the operator's first-line defence -- running the apply-mode flag here is the canonical clean path; `--allow-vbrief-drift` on the pipeline exists only as the explicit-acknowledgment escape hatch (analogous to `--allow-dirty`). The recurrence record is the v0.21.0 cut, which surfaced 13 stranded vBRIEFs (8 cycle-relevant + 5 historical residue) post-publish; the gate now blocks that drift before any irreversible action
|
|
81
82
|
7. ! **Verify the proposed `v<version>` tag is not already in use locally, on origin, or as a published GitHub release** (#784). The release pipeline carries the deterministic gate at Step 4 (`scripts/release.py::check_tag_available`, refuses with `EXIT_VIOLATION` before any state mutation -- CHANGELOG promotion, ROADMAP refresh, build, commit), but Phase 1 is the operator's first-line defence. Quickly probe with `git tag -l v<version>` (local), `git ls-remote --tags origin refs/tags/v<version>` (remote), and `gh release view v<version> --repo <owner>/<repo>` (release-only, where `gh release view` exits 0 only when the release exists). The recurrence record is the v0.22.0 → v0.23.0 release attempt on 2026-05-01: the operator typed `0.22.0` (the prior release from 12 hours earlier) and the legacy pipeline ran 8 steps before failing at `git tag` -- leaving a wrong-version local commit + `dist/deft-0.22.0.zip` orphan + manual `git reset --hard` recovery. The new pre-flight gate blocks that mode before any irreversible action
|
|
82
83
|
8. ! **Verify the npm credential path is configured before cutting the tag** (#1910, #1909). A `v*` tag now auto-triggers `.github/workflows/npm-publish.yml`, which publishes the four `@deftai/directive*` packages with `npm publish --provenance`. Confirm the publish path can authenticate: either the `NPM_TOKEN` repo secret is present (`gh secret list --repo <owner>/<repo>` shows `NPM_TOKEN`) OR an npm OIDC trusted publisher is configured for the `@deftai/directive*` packages. If neither is in place, WARN loudly that the tag will fire a publish job that fails (red X on the tag, no packages) -- the operator may still proceed for a GitHub-only release, but the npm channel will not land until #1909's credential is provisioned. Cross-reference #1909.
|
|
83
|
-
9.
|
|
84
|
+
9. ! **Disclose npm irrevocability before any tag push (#1972, #2002).** A `v<version>` tag push is the **real npm publish gate** -- NOT Phase 5 or `task release:publish`. Tag push fires `.github/workflows/npm-publish.yml` in a separate workflow that is NOT draft-gated; npm packages ship immediately and **cannot be retracted** (`npm unpublish` is forbidden). Recovery is forward-only: deprecate, dist-tag, or ship a patch. The operator's explicit `yes` in Phase 2 (dry-run) and the decision to invoke `task release` in Phase 4 are the last human gates before npm goes live. Phase 5 only controls GitHub release visibility (draft → public); it does NOT gate npm.
|
|
85
|
+
10. ~ Ask the operator for an optional one-line release **summary** (recommended 80-160 chars; can be skipped). The summary is the canonical narrative for THIS release across three audiences: (a) injected as a Markdown blockquote at the top of the promoted `CHANGELOG.md [<version>]` section, (b) auto-flowed into the GitHub release body via the existing `_section_for_version` pickup, and (c) populated VERBATIM into the Phase 8 Slack `*Summary*:` slot. Capture the wording once here; do NOT regenerate per-audience downstream
|
|
84
86
|
|
|
85
87
|
⊗ Skip the version-bump magnitude check -- a patch release that ships breaking changes is the kind of regression that Repair Authority [AXIOM] (#709) is designed to prevent.
|
|
86
88
|
|
|
@@ -100,7 +102,7 @@ task release -- <version> --dry-run --skip-tag --skip-release --summary "<text>"
|
|
|
100
102
|
|
|
101
103
|
The dry-run prints `[N/13] <step>... DRYRUN (would <action>)` for every pipeline step (Step 13 is the post-create verify-isDraft gate added by #724; Step 4 is the tag-availability pre-flight gate added by #784). Step 6 (CHANGELOG promotion) surfaces whether a summary was supplied (truncated to ~60 chars in the preview) so the operator can validate the wording before any file is written. Capture the output and present it to the user, then wait for explicit confirmation before continuing.
|
|
102
104
|
|
|
103
|
-
! Wait for explicit user confirmation: `yes` / `back` / `quit`.
|
|
105
|
+
! Wait for explicit user confirmation: `yes` / `back` / `quit`. Remind the operator that Phase 4's tag push will irrevocably publish npm (#1972) -- this `yes` is the last safe abort before that channel opens.
|
|
104
106
|
- `yes` (or `confirmed` / `approve`) → proceed to Phase 3
|
|
105
107
|
- `back` → return to Phase 1 for re-validation (e.g. user wants to amend the version or `[Unreleased]` content)
|
|
106
108
|
- `quit` → abort the workflow cleanly; no state changes
|
|
@@ -123,10 +125,12 @@ The harness provisions `deftai/deftai-release-test-<ts>-<uuid6>`, runs the smoke
|
|
|
123
125
|
|
|
124
126
|
! **`task release:e2e` now also rehearses the npm publish (#1910).** Unless `--skip-npm` is passed (or `npm` is absent from PATH, which soft-skips), the rehearsal runs `npm publish --dry-run --access public` for all four `@deftai/directive*` packages against the throwaway clone in dependency order (types -> core -> content -> cli), after `pnpm install` + `pnpm -w run build` and a version-alignment pass. This catches a broken `files` allowlist, a version-drift bug, or a dependency-order error BEFORE the real `v*` tag fires the publish workflow -- without touching the real registry. The install+build exceeds the <90s fast budget, so pass `task release:e2e -- --skip-npm` when you only need the GitHub-pipeline shape check.
|
|
125
127
|
|
|
126
|
-
! **Tag -> npm coupling +
|
|
128
|
+
! **Tag -> npm coupling + irrevocability (#1910, #1972, #2002).** A `v<version>` tag is a TWO-channel action: the GitHub release (this skill's pipeline) AND `.github/workflows/npm-publish.yml`, which runs in a SEPARATE workflow that does NOT block the GitHub release and is NOT draft-gated. The npm workflow derives the published version from the tag (`${GITHUB_REF_NAME#v}`); this skill owns the version chosen in Phase 1. These MUST stay consistent -- the tag you cut IS the npm version that ships; there is no separate npm version bump. **npm publish is irrevocable** (#1972): once the tag fires, packages are live on npm and cannot be unpulled; `task release:rollback` does NOT retract npm (forward-only recovery). A red npm job on a green GitHub release means the npm channel did not ship (verify in Phase 5/7).
|
|
127
129
|
|
|
128
130
|
## Phase 4 — Production draft
|
|
129
131
|
|
|
132
|
+
! **Last human gate before npm (#1972, #2002).** Immediately before invoking `task release`, re-state that the tag push in this step will irrevocably publish all four `@deftai/directive*` packages to npm via `.github/workflows/npm-publish.yml`. There is no undo on npm; only forward recovery (deprecate / dist-tag / patch). Proceed only when the operator explicitly confirms.
|
|
133
|
+
|
|
130
134
|
! Invoke `task release -- <version>` (NO `--dry-run`, NO `--skip-tag`, NO `--skip-release`). If Phase 1 collected an operator summary, pass `--summary "<text>"` so the production cut writes the same blockquote the dry-run previewed.
|
|
131
135
|
|
|
132
136
|
```
|
|
@@ -145,25 +149,34 @@ Per #716 default-draft hardening, this lands the release as a `--draft` on the r
|
|
|
145
149
|
|
|
146
150
|
⊗ Skip the post-create verify-isDraft gate -- the gate is the only reliable safety net against "create call exited 0 but the release somehow landed as public" variants (#724). If `task release` is invoked manually outside the canonical `scripts/release.py` flow, the operator MUST run `gh release view v<version> --json isDraft` followed by `gh release edit --draft=true` on `isDraft=false` BEFORE handing off to Phase 5.
|
|
147
151
|
|
|
148
|
-
## Phase 5 —
|
|
152
|
+
## Phase 5 — GitHub draft QA (optional; NOT the npm authority gate)
|
|
153
|
+
|
|
154
|
+
! After `task release` exits 0, QA the **GitHub draft release** only. npm packages typically **already shipped** when the tag push in Phase 4 fired `.github/workflows/npm-publish.yml` (#1972, #2002). Phase 5 is NOT a "user-only authority before going live" gate for the release as a whole -- it is optional draft QA for GitHub assets, notes, and binaries.
|
|
155
|
+
|
|
156
|
+
1. ! **Verify npm publish status FIRST (in parallel with draft inspection).** Run `gh run list --workflow=npm-publish.yml --repo <owner>/<repo> --limit 5` and confirm the tag run for `v<version>` is `completed`/`success`. If npm failed, surface immediately -- the GitHub draft QA is secondary to a red npm channel.
|
|
157
|
+
2. ! Run `gh release view v<version> --json url,name,body,assets,isDraft --repo <owner>/<repo>` and present the output to the user
|
|
158
|
+
3. ! Surface the asset list (size + filename) so the user can verify binaries uploaded correctly
|
|
159
|
+
4. ! Surface the auto-generated release notes (or the CHANGELOG section that was promoted into the release body)
|
|
160
|
+
|
|
161
|
+
### Happy path (default when npm succeeded and draft assets look correct)
|
|
149
162
|
|
|
150
|
-
!
|
|
163
|
+
! When the npm workflow succeeded AND draft assets/notes pass inspection, **auto-proceed to Phase 6 Publish branch** -- run `task release:publish -- <version>` without a redundant human publish prompt (#2002). npm already shipped at tag push; waiting for a separate `publish` confirmation does not protect the npm channel.
|
|
151
164
|
|
|
152
|
-
|
|
153
|
-
2. ! Surface the asset list (size + filename) so the user can verify binaries uploaded correctly
|
|
154
|
-
3. ! Surface the auto-generated release notes (or the CHANGELOG section that was promoted into the release body)
|
|
155
|
-
4. ! Wait for explicit user confirmation:
|
|
156
|
-
- `publish` (or `yes` / `confirmed` / `approve`) → proceed to Phase 6 (Publish branch)
|
|
157
|
-
- `rollback` → proceed to Phase 6 (Rollback branch)
|
|
158
|
-
- `defer` → halt and exit. Surface the draft URL so the operator can return later with `task release:publish` or `task release:rollback`. Do NOT auto-merge; do NOT silently wait
|
|
165
|
+
? **Operator override:** if the operator wants to hold the GitHub release in draft (e.g. embargo, last-minute notes edit), they MAY say `defer` before auto-publish runs.
|
|
159
166
|
|
|
160
|
-
|
|
167
|
+
### Exception paths (operator-initiated)
|
|
168
|
+
|
|
169
|
+
- `rollback` → proceed to Phase 6 (Rollback branch). **Reminder:** rollback unwinds the GitHub release only; npm packages already published at tag push are NOT retracted (#1972).
|
|
170
|
+
- `defer` → halt and exit. Surface the draft URL so the operator can return later with `task release:publish -- <version>` or `task release:rollback -- <version>`.
|
|
171
|
+
|
|
172
|
+
⊗ Treat Phase 5 as the npm publish-authority gate -- npm ships at tag push (Phase 4), not at `task release:publish`. A human `publish` prompt here is redundant when npm already succeeded and only delays flipping the GitHub draft to public.
|
|
173
|
+
⊗ Skip npm workflow verification in Phase 5 and defer it entirely to post-publish Phase 7 -- npm status MUST be checked before or in parallel with the GitHub publish flip.
|
|
161
174
|
|
|
162
175
|
## Phase 6 — Publish or rollback
|
|
163
176
|
|
|
164
|
-
! Branch on the
|
|
177
|
+
! Branch on the Phase 5 outcome. The happy path auto-enters the Publish branch when npm succeeded and draft QA passed (#2002).
|
|
165
178
|
|
|
166
|
-
### Publish branch (
|
|
179
|
+
### Publish branch (happy path auto-run, or resumed after `defer`)
|
|
167
180
|
|
|
168
181
|
```
|
|
169
182
|
task release:publish -- <version>
|
|
@@ -175,7 +188,7 @@ The companion script flips `--draft=false`, then re-reads the release to verify
|
|
|
175
188
|
- `not-found` → exit 1 (cannot publish a missing release)
|
|
176
189
|
- gh-error → exit 1 with diagnostic
|
|
177
190
|
|
|
178
|
-
! Wait for `task release:publish` to exit 0 before continuing.
|
|
191
|
+
! Wait for `task release:publish` to exit 0 before continuing. On the happy path this runs immediately after Phase 5 draft QA without a separate human publish prompt.
|
|
179
192
|
|
|
180
193
|
### Rollback branch (user said `rollback`)
|
|
181
194
|
|
|
@@ -197,15 +210,17 @@ Race-condition mitigation: `download_count` is double-read with a 5s sleep betwe
|
|
|
197
210
|
|
|
198
211
|
! When the guard refuses, surface the recommendation to the user: rollback is risky on a released artifact with non-zero downloads. Prefer the **hot-fix path** (cut the next patch with a withdrawal note in `[Unreleased]/Changed` rather than deleting the broken release).
|
|
199
212
|
|
|
213
|
+
! **`task release:rollback` does NOT retract npm (#1972, #2002).** Rollback unwinds GitHub release state (draft/public, tag, assets) only. npm packages published at tag push remain on the registry irrevocably. Recovery is forward-only: deprecate the bad version, move a dist-tag, or ship a patch release.
|
|
214
|
+
|
|
200
215
|
## Phase 7 — Post-publish verification
|
|
201
216
|
|
|
202
217
|
! Only enter Phase 7 if Phase 6 took the Publish branch (rollback branch ends here with the unwind log).
|
|
203
218
|
|
|
204
|
-
1. !
|
|
205
|
-
2. !
|
|
206
|
-
3. !
|
|
207
|
-
4. ! Verify
|
|
208
|
-
5. !
|
|
219
|
+
1. ! **Re-verify npm publish landed (#1910, #1909, #2002).** Phase 5 checked workflow status before the GitHub publish flip; Phase 7 confirms registry truth AFTER `task release:publish`. For each of `@deftai/directive-types`, `@deftai/directive-core`, `@deftai/directive-content`, and `@deftai/directive`, run `npm view <pkg>@<version> version` (expect `<version>`) and confirm provenance on the npm page. A green GitHub release with missing npm packages means consumers cannot `npm i -g @deftai/directive@<version>` -- escalate immediately. (Real-registry verification depends on #1909's credential; until then verify workflow-run status and flag credential gaps.)
|
|
220
|
+
2. ! Verify GitHub auto-closed the discrete-task issue(s) referenced via `Closes #N` in the release notes (mirrors `skills/deft-directive-swarm/SKILL.md` Phase 6 Step 2)
|
|
221
|
+
3. ! Run `gh issue view <N> --json state --jq .state` for each closed issue. If any didn't auto-close, manually close with `gh issue close <N> --comment "Closed by release v<version> (squash auto-close did not trigger)"` (Layer 1, #167)
|
|
222
|
+
4. ! Verify ROADMAP.md correctness via `task roadmap:render` (the release pipeline already invoked this; Phase 7 is the second-pass sanity check)
|
|
223
|
+
5. ! Verify binaries are downloadable from the public release URL: `gh release view v<version> --json assets --jq '.assets[].url'` and curl one to confirm 200 OK
|
|
209
224
|
6. ! For any umbrella / staying-OPEN issue (`Refs #N`) referenced in the release notes, run the Layer 3 reopen sweep from `skills/deft-directive-swarm/SKILL.md` Phase 6 Step 1: any protected issue that auto-closed MUST be reopened with a comment citing #701
|
|
210
225
|
|
|
211
226
|
⊗ Skip the post-publish verification. The closing-keyword false-positive (Layer 1 / Layer 2 / Layer 3) and the incremental-renderer-drift (#641, #614) are exactly the kind of issues that surface only AFTER a release is public.
|
|
@@ -246,7 +261,7 @@ Next: <one-line guidance>
|
|
|
246
261
|
Where `<one-line guidance>` is one of:
|
|
247
262
|
- "release v<version> live -- monitor consumer reports for ~24h before cutting v<next>"
|
|
248
263
|
- "release v<version> rolled back -- the underlying defect needs a hot-fix in the next CHANGELOG entry"
|
|
249
|
-
- "release deferred -- resume by running `task release:publish -- <version>` (or `task release:rollback -- <version>`) when ready"
|
|
264
|
+
- "release deferred -- resume by running `task release:publish -- <version>` (GitHub only; npm already shipped at tag push) or `task release:rollback -- <version>` (GitHub unwind only; npm is forward-recovery) when ready"
|
|
250
265
|
|
|
251
266
|
⊗ Exit silently without confirming completion or providing next-step guidance.
|
|
252
267
|
|
|
@@ -255,7 +270,8 @@ Where `<one-line guidance>` is one of:
|
|
|
255
270
|
- ⊗ Run `task release` without a Phase 2 dry-run preview -- the dry-run is the only safe place to catch a bad version, malformed CHANGELOG, or wrong base branch
|
|
256
271
|
- ⊗ Skip Phase 3 (e2e rehearsal) on the assumption that "the dry-run is enough" -- the e2e harness catches gh-CLI auth issues, repo permission gaps, and pipeline-shape regressions that the dry-run cannot detect
|
|
257
272
|
- ⊗ Pass `--no-draft` to `task release` without explicit operator opt-in -- the default-draft contract is the foundation of the safety hardening surface
|
|
258
|
-
- ⊗
|
|
273
|
+
- ⊗ Treat Phase 5 as the npm authority gate or require a redundant human `publish` prompt when npm already succeeded -- npm ships at tag push (#1972); Phase 5 is GitHub draft QA only
|
|
274
|
+
- ⊗ Expect `task release:rollback` to retract npm packages -- rollback is GitHub-only; npm recovery is forward-only (deprecate / dist-tag / patch)
|
|
259
275
|
- ⊗ Run `task release:rollback` against a release that has > 30 minutes of consumer-driven downloads without first weighing the hot-fix path -- a withdrawal note in the next patch is almost always less disruptive than deleting a public artifact
|
|
260
276
|
- ⊗ Use `--allow-data-loss` without first reading the script docstring's hot-fix-path recommendation -- the flag is an explicit acknowledgment of consumer impact, not a default
|
|
261
277
|
- ⊗ Skip the Phase 7 Layer 3 reopen sweep -- protected umbrellas can auto-close on a release-merge squash even when the release notes use `Refs #N` only
|