@marcomigozzi/trailhead 0.15.0 → 0.16.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/README.it.md +45 -9
- package/README.md +46 -10
- package/bin/auto-intro-docs.test.js +72 -0
- package/bin/auto-merge-docs.test.js +33 -0
- package/bin/auto-merge-ticket-docs.test.js +32 -0
- package/bin/auto-review-rounds-docs.test.js +45 -0
- package/bin/auto-run-docs.test.js +60 -0
- package/bin/auto-scope-docs.test.js +33 -0
- package/bin/auto-slots-docs.test.js +45 -0
- package/bin/auto-uat-docs.test.js +47 -0
- package/bin/grilling-holistic-review-docs.test.js +90 -0
- package/bin/lib/codex-projection.js +5 -5
- package/bin/lib/codex-projection.test.js +2 -2
- package/bin/needs-you-docs.test.js +41 -0
- package/bin/pick-stress-test-docs.test.js +105 -0
- package/bin/trailhead.js +3 -3
- package/bin/trailhead.test.js +17 -18
- package/package.json +1 -1
- package/plugins/trailhead/.claude-plugin/plugin.json +1 -1
- package/plugins/trailhead/commands/auto.md +2 -2
- package/plugins/trailhead/hooks/lib/auto-merge.test.js +467 -0
- package/plugins/trailhead/hooks/lib/auto-queue.test.js +245 -0
- package/plugins/trailhead/hooks/lib/auto-run.test.js +318 -0
- package/plugins/trailhead/hooks/lib/auto-slot.test.js +278 -0
- package/plugins/trailhead/hooks/lib/checkout-in.test.js +173 -0
- package/plugins/trailhead/hooks/lib/dashboard.test.js +66 -3
- package/plugins/trailhead/hooks/lib/merge-ticket.test.js +394 -0
- package/plugins/trailhead/hooks/lib/needs-you.test.js +177 -0
- package/plugins/trailhead/hooks/lib/plan-review-rounds.test.js +319 -0
- package/plugins/trailhead/hooks/lib/statusline-context.test.js +140 -0
- package/plugins/trailhead/hooks/lib/uat-followup.test.js +169 -0
- package/plugins/trailhead/hooks/lib/unblock.test.js +207 -0
- package/plugins/trailhead/hooks/mod/README.md +180 -39
- package/plugins/trailhead/hooks/mod/auto-run-view.ts +93 -0
- package/plugins/trailhead/hooks/mod/auto-run.ts +165 -0
- package/plugins/trailhead/hooks/mod/map-pane-view.ts +150 -25
- package/plugins/trailhead/hooks/mod/map-pane.tsx +526 -92
- package/plugins/trailhead/hooks/mod/register.tsx +298 -39
- package/plugins/trailhead/hooks/mod/repo-pin.ts +167 -0
- package/plugins/trailhead/hooks/mod/session-registry.ts +65 -0
- package/plugins/trailhead/hooks/mod/turn-state.ts +14 -0
- package/plugins/trailhead/hooks/mod/whiteboard-follow.ts +98 -0
- package/plugins/trailhead/hooks/mod/work-state-locate.ts +191 -0
- package/plugins/trailhead/hooks/mod/work-state-prompt.ts +13 -15
- package/plugins/trailhead/hooks/mod/work-state-view.ts +21 -5
- package/plugins/trailhead/hooks/mod/worktree-porcelain.ts +17 -0
- package/plugins/trailhead/hooks/trailhead-bash-guards.js +8 -8
- package/plugins/trailhead/hooks/trailhead-bash-guards.test.js +22 -16
- package/plugins/trailhead/hooks/trailhead-labels-guard.js +231 -0
- package/plugins/trailhead/hooks/trailhead-labels-guard.test.js +212 -0
- package/plugins/trailhead/hooks/trailhead-secret-read-guard.js +296 -22
- package/plugins/trailhead/hooks/trailhead-secret-read-guard.test.js +253 -0
- package/plugins/trailhead/skills/_shared/choices.md +7 -2
- package/plugins/trailhead/skills/_shared/configuration-reference.md +3 -3
- package/plugins/trailhead/skills/_shared/substrate-commands.md +26 -4
- package/plugins/trailhead/skills/_shared/substrate.md +4 -1
- package/plugins/trailhead/skills/_shared/teamwork.md +2 -2
- package/plugins/trailhead/skills/_shared/techniques/acceptance-testing.md +2 -2
- package/plugins/trailhead/skills/_shared/techniques/grilling.md +4 -2
- package/plugins/trailhead/skills/_shared/techniques/plan-review.md +3 -1
- package/plugins/trailhead/skills/trailhead-capture/references/capture.md +1 -1
- package/plugins/trailhead/skills/trailhead-chart/SKILL.md +1 -1
- package/plugins/trailhead/skills/trailhead-chart/references/charting.md +4 -4
- package/plugins/trailhead/skills/trailhead-chart/references/inbox.md +2 -2
- package/plugins/trailhead/skills/trailhead-view/SKILL.md +1 -0
- package/plugins/trailhead/skills/trailhead-work/SKILL.md +21 -10
- package/plugins/trailhead/skills/trailhead-work/references/auto.md +70 -22
- package/plugins/trailhead/skills/trailhead-work/references/ticket-engines.md +6 -6
- package/plugins/trailhead/templates/statusline-trailhead.sh +77 -12
- package/plugins/trailhead/templates/trailhead-auto-merge.js +318 -0
- package/plugins/trailhead/templates/trailhead-auto-queue.js +186 -0
- package/plugins/trailhead/templates/trailhead-auto-run.js +294 -0
- package/plugins/trailhead/templates/trailhead-auto-slot.js +239 -0
- package/plugins/trailhead/templates/trailhead-checkout-in.js +88 -0
- package/plugins/trailhead/templates/trailhead-dashboard.js +32 -12
- package/plugins/trailhead/templates/trailhead-merge-ticket.js +366 -0
- package/plugins/trailhead/templates/trailhead-needs-you.js +193 -0
- package/plugins/trailhead/templates/trailhead-plan-review-rounds.js +289 -0
- package/plugins/trailhead/templates/trailhead-uat-followup.js +128 -0
- package/plugins/trailhead/templates/trailhead-unblock.js +111 -0
- package/plugins/trailhead/tests/auto-run-ui.test.ts +444 -0
- package/plugins/trailhead/tests/auto-run-view.test.ts +111 -0
- package/plugins/trailhead/tests/map-pane-ui.test.ts +1732 -142
- package/plugins/trailhead/tests/map-pane-view.test.ts +308 -14
- package/plugins/trailhead/tests/map-pane-write.test.ts +51 -0
- package/plugins/trailhead/tests/repo-pin-late-repo.test.ts +77 -0
- package/plugins/trailhead/tests/repo-pin.test.ts +123 -0
- package/plugins/trailhead/tests/session-registry.test.ts +50 -0
- package/plugins/trailhead/tests/whiteboard-follow.test.ts +90 -0
- package/plugins/trailhead/tests/work-state-locate.test.ts +33 -0
- package/plugins/trailhead/tests/work-state-prompt-hook.test.ts +276 -2
- package/plugins/trailhead/tests/work-state-prompt.test.ts +29 -35
- package/plugins/trailhead/tests/work-state-ui.test.ts +73 -1
- package/plugins/trailhead/tests/work-state-view.test.ts +19 -0
- package/plugins/trailhead/tests/work-state-worktree.test.ts +310 -0
- package/plugins/trailhead/types/index.d.ts +26 -1
|
@@ -14,8 +14,8 @@ A ticket whose body carries a `UAT of: [<parent name>](<parent url>)` line is a
|
|
|
14
14
|
## `decision`: HITL, *default for decision tickets*
|
|
15
15
|
Close a choice, but widen before you narrow:
|
|
16
16
|
|
|
17
|
-
1. **Diverge (only if the option space isn't already clear).** Mapping the frontier usually framed the options already;
|
|
18
|
-
2. **Converge.** Run the **Grilling** + **Domain vocabulary** techniques over the options to pick one. Record the decision and the why. The delegate option (agent picks) is never offered here, the choice stays the human's, but **defer is offered** (in prose, per the recipe in `../../_shared/choices.md`) when the choice is genuinely blocked on a prerequisite.
|
|
17
|
+
1. **Diverge (only if the option space isn't already clear).** Mapping the frontier usually framed the options already; when it did, check that the framed set is still current and complete before grilling: re-read it against the ticket and against what has landed since (the map's `Decisions so far`, tickets closed after it was framed), add any missing candidate (the status quo, "leave it as it is", included when it is a real option), post the additions as an `OPTIONS` comment, then grill. When it didn't (the ticket names a question but the candidate answers aren't laid out) open the space *first*: brainstorm the plausible options, name each concretely, and post them as an `OPTIONS` comment. Divergence has no judgement yet: surface possibilities, don't rank them. If a missing **fact** is what's blocking clarity, spin a `research` ticket and block on it; if the question is "how could it look/behave", spin a `prototype`. Don't grill a space you haven't opened.
|
|
18
|
+
2. **Converge.** Run the **Grilling** + **Domain vocabulary** techniques over the options to pick one. Before the final confirmation, run the **Holistic review before the final confirmation** (Grilling technique). Record the decision and the why. The delegate option (agent picks) is never offered here, the choice stays the human's, but **defer is offered** (in prose, per the recipe in `../../_shared/choices.md`) when the choice is genuinely blocked on a prerequisite.
|
|
19
19
|
|
|
20
20
|
The two phases are distinct on purpose: divergence generates, grilling chooses. Never let the agent invent the options *and* pick for the human: the choice stays theirs.
|
|
21
21
|
|
|
@@ -30,8 +30,8 @@ The ticket that *builds*. Lean cycle, all in **ticket comments**:
|
|
|
30
30
|
|
|
31
31
|
**Size triage (`effort`-gated, before Discuss).** When `config.effort` is `lean`, run the **size triage and ask-to-skip offer defined canonically under effort in `../../_shared/configuration-reference.md`** before Discuss: classify the change, and on trivial/small surface the one consolidated offer (its skippable steps here: the plan subagent -> a short inline plan; TDD; Cross-AI plan review; the full Code-review subagent -> a lighter inline pass; the Goal-backward verification subagent -> a quick inline goal check), going lean **only on an explicit yes**. On `normal`, on a no/silence/ambiguity, and always under `effort: standard` (the default), run the full cycle below unchanged. The correctness spine (atomic `Refs:` commits, the tests / the plan's criterion, the post-commit self-check, Resolve) is never in the offer.
|
|
32
32
|
|
|
33
|
-
1. **Discuss**: **never auto-grill.** Start from Plan. If reading the ticket surfaces blocking ambiguity, **stop and ask the user** whether they want a **Grilling** round: don't launch it on your own initiative. Only on their assent run the short round and post a `DISCUSS` comment with the closed choices. The user may also ask for it themselves at any time. Advisory choices raised here **offer** the delegate/defer options, per the injection recipe in `../../_shared/choices.md`.
|
|
34
|
-
2. **Plan**: a `PLAN` comment: the steps, the seams, the files touched, the verification criteria. Apply **TDD** per `config.tdd` (`seams`/`on`/`off`). Produce this plan via a *planner subagent* on `config.models.plan` (dispatch the **`trailhead-plan`** agent, read-only, passing `config.models.plan` as the model override; inline when the model is unset or equals the session model; Execute then runs as its own subagent, see below). **Report (orchestrator).** Run the report-status check on the planner's report (`--technique plan`, the *Malformed review report* protocol in `../../_shared/techniques.md`) and branch on the checker's `verdict`: `status: ready` -> post the checker's `body` verbatim as the `PLAN` comment; `status: needs_input` -> ask the user the `questions:` (the checker's `fields`), then re-spawn the planner with the answers; never answer them yourself or guess. If `config.plan_review` is on, run **Cross-AI plan review** before Execute: send the plan to external AI CLIs and converge on their concerns. Now that the files/seam are known, run the **undeclared-coupling check** (`../../_shared/teamwork.md`) against in-progress tickets. **Plan to sufficiency:** stop once you can name the steps, seams, and files with confidence, and never re-read a file already read in this planning pass; act when you have enough to plan, rather than re-deriving what the pass already established. **The single exception:** under an accepted `lean` skip (see the Size triage above), produce a short inline plan here instead of the planner subagent, and skip Cross-AI plan review; absent that accepted skip, this step runs in full as described.
|
|
33
|
+
1. **Discuss**: **never auto-grill.** Start from Plan. If reading the ticket surfaces blocking ambiguity, **stop and ask the user** whether they want a **Grilling** round: don't launch it on your own initiative. Only on their assent run the short round (ending with the **Holistic review before the final confirmation** of the Grilling technique) and post a `DISCUSS` comment with the closed choices, recorded after the review. The user may also ask for it themselves at any time. Advisory choices raised here **offer** the delegate/defer options, per the injection recipe in `../../_shared/choices.md`.
|
|
34
|
+
2. **Plan**: a `PLAN` comment: the steps, the seams, the files touched, the verification criteria. Apply **TDD** per `config.tdd` (`seams`/`on`/`off`). Produce this plan via a *planner subagent* on `config.models.plan` (dispatch the **`trailhead-plan`** agent, read-only, passing `config.models.plan` as the model override; inline when the model is unset or equals the session model; Execute then runs as its own subagent, see below). **Report (orchestrator).** Run the report-status check on the planner's report (`--technique plan`, the *Malformed review report* protocol in `../../_shared/techniques.md`) and branch on the checker's `verdict`: `status: ready` -> post the checker's `body` verbatim as the `PLAN` comment; `status: needs_input` -> ask the user the `questions:` (the checker's `fields`), then re-spawn the planner with the answers; never answer them yourself or guess. If `config.plan_review` is on, run **Cross-AI plan review** before Execute: send the plan to external AI CLIs and converge on their concerns. Now that the files/seam are known, run the **undeclared-coupling check** (`../../_shared/teamwork.md`) against in-progress tickets. **Plan to sufficiency:** stop once you can name the steps, seams, and files with confidence, and never re-read a file already read in this planning pass; act when you have enough to plan, rather than re-deriving what the pass already established. **Coverage check before `ready`.** Before the PLAN is returned (the short inline plan too), map every `Done when` criterion of the ticket (its `Question` when it has none) to the step(s) and the verification criterion that deliver it, and keep that mapping in the PLAN's verification criteria: a criterion no step delivers is a gap, so add the step, or return `needs_input` when closing it needs a decision. Where the PLAN picks between approaches, the pick is stress-tested per **Stress-test the pick before offering it** in `../../_shared/choices.md`, its `Weak point:` line kept in the PLAN. **The single exception:** under an accepted `lean` skip (see the Size triage above), produce a short inline plan here instead of the planner subagent, and skip Cross-AI plan review; absent that accepted skip, this step runs in full as described.
|
|
35
35
|
3. **Execute**: the orchestrator runs the HITL and tracker moves around an **executor subagent on `config.models.execute`**; the implementing protocol itself lives in `executor-brief.md` (this directory), the only protocol text the agent receives from this step. The agent cannot ask the user and never writes to the tracker, pushes or creates a branch.
|
|
36
36
|
- **Prototype FIRST (orchestrator).** **If this ticket changes user-facing UI (a new screen, a redesign, a changed layout), run the Prototype technique FIRST, before any real UI code**: surface a mockup to react to (its clickable link always in the same message as the approval question, per the Prototype technique; the guard also accepts a link inside the approval question itself, the prose stays preferred), routed by `config.design` (disk / artifacts / stitch) and gated by `config.design.approval` (`explicit` waits for a go-ahead, `auto` proceeds after surfacing). **Route by `config.design` and never shortcut a hosted mode to a local disk mockup**: when `config.design` is `artifacts` (or the legacy `claude.ai/design`) or `stitch`, the mockup is built there (reusing a cached `design.project`); `disk` is only for `config.design: disk` or the technique's explicit, announced fallback, never a convenience default. Read `config.design` before producing anything. For a redesign or new screen the default is to offer mockups; don't jump straight to code on a UI ticket (full statement in the **Prototype** technique, `../../_shared/techniques/prototype.md`).
|
|
37
37
|
- **Branch and worktree (orchestrator).** Following the repo's `trailhead:conventions` `git:`, straight to `main` by default, or a feature branch + PR when `git: pr` (the orchestrator creates the branch before the spawn and opens the PR; the executor only commits, never pushes, switches branch or writes to the tracker). **Under `isolation: worktree`, the work happens in this ticket's `git worktree`** (`trailhead/t<n>` branch), integrating to the trunk at Resolve (see `../../_shared/teamwork.md`).
|
|
@@ -42,7 +42,7 @@ The ticket that *builds*. Lean cycle, all in **ticket comments**:
|
|
|
42
42
|
- **Surfaced scope (orchestrator).** New scope surfacing mid-execute, reported by the executor in `surfaced scope:`, is captured/split, not worked here.
|
|
43
43
|
- **Report (orchestrator).** Run the report-status check on the agent's report (`--technique executor`, the *Malformed review report* protocol in `../../_shared/techniques.md`) and branch on the checker's `verdict`, reading its `fields`: never reconstruct a result from the prose. `status: done` -> Verify. `status: partial` or `blocked` -> act on each `blockers:` entry with the moves above (flagged package, infeasible element; a foreign change in the way is raised with the user), then re-spawn. Post `fidelity:` as the fidelity comment and route `surfaced scope:` as below. Check `commits:` against the repo: it must equal the `Refs: #<n>` commit set since the base SHA, in both directions, and every SHA must resolve (`git cat-file -e <sha>^{commit}`); a mismatch is surfaced to the user, never patched over.
|
|
44
44
|
4. **Verify**: a `VERIFY` comment: **start with the Stale-base preflight** (`trailhead-base-preflight.js --sync`, **Stale-base preflight** in `../../_shared/teamwork.md`): preflight, then sync, then re-run the tests, all before anything is reviewed (a rebase rewrites SHAs the review ledger cites, and the Code review and goal check must see the synced tree); then run the tests / the plan's criterion; then the **Code review** technique, scoped to the ticket's own commits per its **Ticket diff** rule (`../../_shared/techniques/code-review.md`: the `Refs: #<n>` set, read per commit, recomputed each round, an empty set surfaced as an error, never a `<base>...HEAD` range) (its subagent reviews *adversarially*: it doesn't defer to a `CLAUDE.md` convention or a "settled decision" memory), and carries the review's counts line and findings ledger, per code-review.md, **run in the background and collected by polling, never a blind blocking wait** (a hard engine default on every host, see *Reviews never block the session* in `../../_shared/techniques.md`); run the report-status check before branching on status (each report, from the subagent or inline, goes through the *Malformed review report* protocol in `../../_shared/techniques.md`, which also fixes the layout of the round's single `VERIFY` comment); **read the review's leading `status:` tag to branch deterministically: `status: clean` -> proceed to Resolve, `status: issues_found` -> iterate the fix-and-re-review loop (or run the HITL checkpoint) before resolving**; then the **Goal-backward verification** technique on `config.models.verify` (does the change deliver what the ticket promised, backward from its `Question` and the `PLAN`'s criteria; the read-only `trailhead-verify` subagent, on the same **Ticket diff** set), **run in the background and collected by polling** like Code review, branching on its leading `status:` tag (`status: met` -> proceed, `status: gaps_found` -> loop back to Execute for the gap or run the HITL checkpoint before resolving, `status: inconclusive` as the leading tag (all gaps cleared) -> a per-reason-class HITL checkpoint with no auto-retry, never auto-resolving over the unevaluated goal); then **Acceptance testing** if the change is user-facing, opening with the UAT setup (`trailhead-uat-setup.js`, after the preflight) and then the drive-mode ask (`../../_shared/techniques/acceptance-testing.md`): AI-driven, or **walk the user through a guided UAT conversationally, one step at a time**, never a checklist dumped in a comment (or skip (waive acceptance), recorded as waived, if the user so chooses): interactive by design, so it runs inline, not backgrounded. Report the outcome honestly (if a test fails, say so). **The single exception:** under an accepted `lean` skip, run a lighter inline review pass instead of the full Code-review subagent and a quick inline goal check instead of the full Goal-backward verification subagent (or skip either only when the change is truly cosmetic); the lighter inline review still uses the same finding IDs, counts line, and ledger, and the same no-`open` gate, per code-review.md; the tests / criterion and the honest outcome report still run regardless. At a HITL checkpoint here, an advisory sub-choice (which fix, how to proceed) **offers** the delegate option, per the injection recipe in `../../_shared/choices.md`; the accept/resolve decision stays the human's.
|
|
45
|
-
5. **Resolve**: a resolution comment with what was done → `gh issue close` → update `Decisions so far` on the map (default path: `node <templates>/trailhead-issue-body.js append --issue <map> --section "Decisions so far" --line "<pointer>"`, a body read-modify-write that obeys the **Fail-safe body rewrites** rule in `../../_shared/substrate.md` for you; `<templates>` is the same three-dir lookup as for `trailhead-work-state.js` (**Work state file** in `../../_shared/teamwork.md`); when the helper is missing, do the read-modify-write by hand per that rule). **Disposition gate (before the close)**: the latest `VERIFY` comment's findings ledger covers every finding the counts line counts and none of them is `open`; the resolution comment links that ledger rather than duplicating it; otherwise do not `gh issue close` (per code-review.md's Disposition gate). **Stale-base preflight (before integrating).** Run the preflight per **Stale-base preflight** in `../../_shared/teamwork.md` (`git: main`: right before the trunk push; `git: pr` without isolation: on the ticket branch, immediately before the push and PR; `worktree`/`clone`: on the main checkout, then on the ticket branch) and integrate only on `current`/`synced` with the tests green and `checksOk`. **Pending-UAT guard (before the close).** If Verify left acceptance testing **not yet run** (it would otherwise be deferred), **pose the two-way choice to the user and wait for an explicit answer, never open a ticket unilaterally**: (a) run the UAT now, in this session, with no ticket (the stale-base preflight first, then the UAT setup, then the drive-mode ask, `../../_shared/techniques/acceptance-testing.md`), or (b) open a UAT follow-up ticket to run it later. On (a), run the guided walkthrough now and resolve on a confirmed pass, or on an explicit waiver if the user skips at the ask (recorded as unconfirmed; the resolution comment states it) (a failing step becomes a fix or a `bug` follow-up and an incomplete/blocked step is marked pending and tracked, per the guided-UAT rules in `../../_shared/techniques/acceptance-testing.md`, not a silent close); on (b), **open the follow-up ticket carrying the owed UAT first**, linked back to this parent (a `task` on the frontier or a `trailhead:seed` gated on the run, per *Pending UAT must stay tracked* in `../../_shared/techniques/acceptance-testing.md`). **Never open a UAT ticket without asking, never `gh issue close` on a chosen-deferred UAT with nothing opened, and never let silence or an ambiguous reply default to either path.** When acceptance was already completed at Verify, or was **explicitly waived** by the user at the drive-mode ask (a waiver is not a deferral: nothing is owed, so nothing is opened; the resolution comment states the waiver), this guard doesn't fire. **Post-commit self-check (before the comment goes out).** For each commit the comment cites, confirm the hash resolves (`git cat-file -e <hash>^{commit}`, or `git show --stat <hash>`) and that every file the comment names as touched actually appears in that commit's diff (`git -c core.quotepath=false show --name-only --format= <hash>`, with `core.quotepath=false` so a non-ASCII filename compares plainly instead of in git's C-quoted form); correct or drop any citation that fails the check, so a hallucinated or mis-stated commit never lands in the tracker record. **If this change materially altered the codebase** (a new module/layer, dependency, seam, or build/test command), **patch the affected facet of the `trailhead:codebase` issue** (see **Codebase** in `../../_shared/substrate.md
|
|
45
|
+
5. **Resolve**: a resolution comment with what was done → `gh issue close` → update `Decisions so far` on the map (default path: `node <templates>/trailhead-issue-body.js append --issue <map> --section "Decisions so far" --line "<pointer>"`, a body read-modify-write that obeys the **Fail-safe body rewrites** rule in `../../_shared/substrate.md` for you; `<templates>` is the same three-dir lookup as for `trailhead-work-state.js` (**Work state file** in `../../_shared/teamwork.md`); when the helper is missing, do the read-modify-write by hand per that rule). **Disposition gate (before the close)**: the latest `VERIFY` comment's findings ledger covers every finding the counts line counts and none of them is `open`; the resolution comment links that ledger rather than duplicating it; otherwise do not `gh issue close` (per code-review.md's Disposition gate). **Stale-base preflight (before integrating).** Run the preflight per **Stale-base preflight** in `../../_shared/teamwork.md` (`git: main`: right before the trunk push; `git: pr` without isolation: on the ticket branch, immediately before the push and PR; `worktree`/`clone`: on the main checkout, then on the ticket branch) and integrate only on `current`/`synced` with the tests green and `checksOk`. **Pending-UAT guard (before the close).** If Verify left acceptance testing **not yet run** (it would otherwise be deferred), **pose the two-way choice to the user and wait for an explicit answer, never open a ticket unilaterally**: (a) run the UAT now, in this session, with no ticket (the stale-base preflight first, then the UAT setup, then the drive-mode ask, `../../_shared/techniques/acceptance-testing.md`), or (b) open a UAT follow-up ticket to run it later. (Under an autonomous run the guard takes option (b) itself for the human-only steps, reusing an open follow-up instead of opening a second one: see `auto.md`.) On (a), run the guided walkthrough now and resolve on a confirmed pass, or on an explicit waiver if the user skips at the ask (recorded as unconfirmed; the resolution comment states it) (a failing step becomes a fix or a `bug` follow-up and an incomplete/blocked step is marked pending and tracked, per the guided-UAT rules in `../../_shared/techniques/acceptance-testing.md`, not a silent close); on (b), **open the follow-up ticket carrying the owed UAT first**, linked back to this parent (a `task` on the frontier or a `trailhead:seed` gated on the run, per *Pending UAT must stay tracked* in `../../_shared/techniques/acceptance-testing.md`). **Never open a UAT ticket without asking, never `gh issue close` on a chosen-deferred UAT with nothing opened, and never let silence or an ambiguous reply default to either path.** When acceptance was already completed at Verify, or was **explicitly waived** by the user at the drive-mode ask (a waiver is not a deferral: nothing is owed, so nothing is opened; the resolution comment states the waiver), this guard doesn't fire. **Post-commit self-check (before the comment goes out).** For each commit the comment cites, confirm the hash resolves (`git cat-file -e <hash>^{commit}`, or `git show --stat <hash>`) and that every file the comment names as touched actually appears in that commit's diff (`git -c core.quotepath=false show --name-only --format= <hash>`, with `core.quotepath=false` so a non-ASCII filename compares plainly instead of in git's C-quoted form); correct or drop any citation that fails the check, so a hallucinated or mis-stated commit never lands in the tracker record. **If this change materially altered the codebase** (a new module/layer, dependency, seam, or build/test command), **patch the affected facet of the `trailhead:codebase` issue** (see **Codebase** in `../../_shared/substrate.md`; look it up per its singleton fail-closed rule) in place (a line or two, not a re-map). When a structural event is due (a whiteboard ticket resolved, a map exhausted), refresh the dashboard with `node <templates>/trailhead-dashboard.js` (one call, whole body from the live tracker; by hand per `../../_shared/substrate-commands.md` when the helper is missing). Then close the session with the **Session handoff** ritual (`../../_shared/session-handoff.md`): one `node <templates>/trailhead-work-state.js clear --repo <working-root>` removes the work state and the session-ticket marker, then `/clear` + next command.
|
|
46
46
|
|
|
47
47
|
New scope that surfaces mid-build (while planning, executing, or testing) → **capture or split, never expand this ticket in flight** (see **Scope that surfaces while working a ticket**, served by the capture cluster). If the user defers that in-the-moment choice rather than settling it, mark the follow-up **pending** so the **Pending follow-up guard** re-poses it at handoff (`../../_shared/session-handoff.md`), instead of losing it to scrollback.
|
|
48
48
|
|
|
@@ -69,7 +69,7 @@ Then run the cycle, all in ticket comments:
|
|
|
69
69
|
- **Surfaced scope (orchestrator).** New scope the executor reports in `surfaced scope:` is captured/split, never worked in this ticket.
|
|
70
70
|
- **Report (orchestrator).** Run the report-status check on the agent's report (`--technique fix`, the *Malformed review report* protocol in `../../_shared/techniques.md`) and branch on the checker's `verdict`, reading its `fields`: never reconstruct a result from the prose. `status: done` -> Verify. `status: partial` or `blocked` -> act on each `blockers:` entry with the moves above (flagged package, infeasible element; a foreign change in the way is raised with the user), then re-spawn. Post `fidelity:` as the fidelity comment and route `surfaced scope:` as below. Check `commits:` against the repo: it must equal the `Refs: #<n>` commit set since the base SHA, in both directions, and every SHA must resolve (`git cat-file -e <sha>^{commit}`); a mismatch is surfaced to the user, never patched over.
|
|
71
71
|
4. **Verify**: a `VERIFY` comment: **start with the Stale-base preflight** (`trailhead-base-preflight.js --sync`, **Stale-base preflight** in `../../_shared/teamwork.md`): preflight, then sync, then re-run the tests, all before anything is reviewed (a rebase rewrites SHAs the review ledger cites, and the Code review and goal check must see the synced tree); then the repro now passes, no regressions; **Code review** on the fix, scoped to the ticket's own commits per its **Ticket diff** rule (`../../_shared/techniques/code-review.md`: the `Refs: #<n>` set, read per commit, recomputed each round, an empty set surfaced as an error, never a `<base>...HEAD` range), and carries the review's counts line and findings ledger, per code-review.md, **run in the background and collected by polling, never a blind blocking wait** (a hard engine default on every host, see *Reviews never block the session* in `../../_shared/techniques.md`); run the report-status check before branching on status (each report, from the subagent or inline, goes through the *Malformed review report* protocol in `../../_shared/techniques.md`, which also fixes the layout of the round's single `VERIFY` comment); **read the review's leading `status:` tag to branch deterministically (`status: clean` -> Resolve, `status: issues_found` -> iterate the loop or run the checkpoint first)**; then the **Goal-backward verification** technique on `config.models.verify` (does the fix deliver what the ticket promised, backward from its `Question`; the read-only `trailhead-verify` subagent, on the same **Ticket diff** set), background and collected by polling, branching on its leading `status:` tag (`status: met` -> proceed, `status: gaps_found` -> loop back to Fix for the gap or run the checkpoint, `status: inconclusive` as the leading tag (all gaps cleared) -> a per-reason-class HITL checkpoint with no auto-retry, never auto-resolving over the unevaluated goal); **Acceptance testing** if the bug was user-facing, opening with the UAT setup (`trailhead-uat-setup.js`, after the preflight) and then the drive-mode ask (`../../_shared/techniques/acceptance-testing.md`): AI-driven, or **walk the user through a guided UAT conversationally, step by step**, not a checklist to self-serve (or skip (waive acceptance), recorded as waived, if the user so chooses): interactive by design, so it runs inline, not backgrounded. At a HITL checkpoint here, an advisory sub-choice (which fix, how to proceed) **offers** the delegate option, per the injection recipe in `../../_shared/choices.md`; the accept/resolve decision stays the human's.
|
|
72
|
-
5. **Resolve**: a resolution comment (cause + fix) → `gh issue close` → update `Decisions so far` (default path: `node <templates>/trailhead-issue-body.js append --issue <map> --section "Decisions so far" --line "<pointer>"`, same helper and fallback as the `build` Resolve step; a body read-modify-write that obeys the **Fail-safe body rewrites** rule in `../../_shared/substrate.md`). **Disposition gate (before the close)**: the latest `VERIFY` comment's findings ledger covers every finding the counts line counts and none of them is `open`; the resolution comment links that ledger rather than duplicating it; otherwise do not `gh issue close` (per code-review.md's Disposition gate). **Stale-base preflight (before integrating).** Run the preflight per **Stale-base preflight** in `../../_shared/teamwork.md` (`git: main`: right before the trunk push; `git: pr` without isolation: on the ticket branch, immediately before the push and PR; `worktree`/`clone`: on the main checkout, then on the ticket branch) and integrate only on `current`/`synced` with the tests green and `checksOk`. **Pending-UAT guard (before the close).** If Verify left acceptance testing **not yet run** (it would otherwise be deferred), **pose the two-way choice to the user and wait for an explicit answer, never open a ticket unilaterally**: (a) run the UAT now, in this session, with no ticket (the stale-base preflight first, then the UAT setup, then the drive-mode ask, `../../_shared/techniques/acceptance-testing.md`), or (b) open a UAT follow-up ticket to run it later. On (a), run the guided walkthrough now and resolve on a confirmed pass, or on an explicit waiver if the user skips at the ask (recorded as unconfirmed; the resolution comment states it) (a failing step becomes a fix or a `bug` follow-up and an incomplete/blocked step is marked pending and tracked, per the guided-UAT rules in `../../_shared/techniques/acceptance-testing.md`, not a silent close); on (b), **open the follow-up ticket carrying the owed UAT first**, linked back to this parent (a `task` on the frontier or a `trailhead:seed` gated on the run, per *Pending UAT must stay tracked* in `../../_shared/techniques/acceptance-testing.md`). **Never open a UAT ticket without asking, never `gh issue close` on a chosen-deferred UAT with nothing opened, and never let silence or an ambiguous reply default to either path.** When acceptance was already completed at Verify, or was **explicitly waived** by the user at the drive-mode ask (a waiver is not a deferral: nothing is owed, so nothing is opened; the resolution comment states the waiver), this guard doesn't fire. **Run the post-commit self-check before posting, exactly as in the `build` Resolve step** (each cited hash resolves and the files it names appear in that commit's diff; correct or drop any citation that fails). **If the fix materially altered the codebase**, patch the affected facet of the `trailhead:codebase` issue (see **Codebase** in `../../_shared/substrate.md
|
|
72
|
+
5. **Resolve**: a resolution comment (cause + fix) → `gh issue close` → update `Decisions so far` (default path: `node <templates>/trailhead-issue-body.js append --issue <map> --section "Decisions so far" --line "<pointer>"`, same helper and fallback as the `build` Resolve step; a body read-modify-write that obeys the **Fail-safe body rewrites** rule in `../../_shared/substrate.md`). **Disposition gate (before the close)**: the latest `VERIFY` comment's findings ledger covers every finding the counts line counts and none of them is `open`; the resolution comment links that ledger rather than duplicating it; otherwise do not `gh issue close` (per code-review.md's Disposition gate). **Stale-base preflight (before integrating).** Run the preflight per **Stale-base preflight** in `../../_shared/teamwork.md` (`git: main`: right before the trunk push; `git: pr` without isolation: on the ticket branch, immediately before the push and PR; `worktree`/`clone`: on the main checkout, then on the ticket branch) and integrate only on `current`/`synced` with the tests green and `checksOk`. **Pending-UAT guard (before the close).** If Verify left acceptance testing **not yet run** (it would otherwise be deferred), **pose the two-way choice to the user and wait for an explicit answer, never open a ticket unilaterally**: (a) run the UAT now, in this session, with no ticket (the stale-base preflight first, then the UAT setup, then the drive-mode ask, `../../_shared/techniques/acceptance-testing.md`), or (b) open a UAT follow-up ticket to run it later. (Under an autonomous run the guard takes option (b) itself for the human-only steps, reusing an open follow-up instead of opening a second one: see `auto.md`.) On (a), run the guided walkthrough now and resolve on a confirmed pass, or on an explicit waiver if the user skips at the ask (recorded as unconfirmed; the resolution comment states it) (a failing step becomes a fix or a `bug` follow-up and an incomplete/blocked step is marked pending and tracked, per the guided-UAT rules in `../../_shared/techniques/acceptance-testing.md`, not a silent close); on (b), **open the follow-up ticket carrying the owed UAT first**, linked back to this parent (a `task` on the frontier or a `trailhead:seed` gated on the run, per *Pending UAT must stay tracked* in `../../_shared/techniques/acceptance-testing.md`). **Never open a UAT ticket without asking, never `gh issue close` on a chosen-deferred UAT with nothing opened, and never let silence or an ambiguous reply default to either path.** When acceptance was already completed at Verify, or was **explicitly waived** by the user at the drive-mode ask (a waiver is not a deferral: nothing is owed, so nothing is opened; the resolution comment states the waiver), this guard doesn't fire. **Run the post-commit self-check before posting, exactly as in the `build` Resolve step** (each cited hash resolves and the files it names appear in that commit's diff; correct or drop any citation that fails). **If the fix materially altered the codebase**, patch the affected facet of the `trailhead:codebase` issue (see **Codebase** in `../../_shared/substrate.md`, looked up per its singleton fail-closed rule; also a body read-modify-write, same **Fail-safe body rewrites** rule) in place (a line or two; the same `trailhead-issue-body.js` helper does it with `--section "<facet>"`). When a structural event is due, refresh the dashboard with `node <templates>/trailhead-dashboard.js`. Then close the session with the **Session handoff** ritual (`../../_shared/session-handoff.md`): one `node <templates>/trailhead-work-state.js clear --repo <working-root>` removes the work state and the session-ticket marker, then `/clear` + next command.
|
|
73
73
|
|
|
74
74
|
A **blocking** bug that halts other work is worked immediately; an isolated one is a normal frontier ticket. New scope surfacing while fixing → capture or split, never expand this ticket in flight (see **Scope that surfaces while working a ticket**). A follow-up raised but left unopened is carried to the **Pending follow-up guard** at handoff (`../../_shared/session-handoff.md`), re-posed at Resolve rather than dropped.
|
|
75
75
|
|
|
@@ -11,8 +11,12 @@
|
|
|
11
11
|
# trailhead al work-start, porta anche il titolo) -> numero dal branch
|
|
12
12
|
# `trailhead/t<N>` -> titolo via `gh` in cache/background (solo se origin GitHub).
|
|
13
13
|
#
|
|
14
|
-
# Contesto: barra `[BARRA] Nk/Mk (P%)`, dall'ultimo usage del transcript. Tetto
|
|
15
|
-
# $CONTEXT_LIMIT -> .
|
|
14
|
+
# Contesto: barra `[BARRA] Nk/Mk (P%)`, dall'ultimo usage del transcript. Tetto
|
|
15
|
+
# (finestra del modello): $CONTEXT_LIMIT -> .context_window.context_window_size ->
|
|
16
|
+
# tabella modello -> auto-bump 1M. La % e' contro la soglia di auto-compact di
|
|
17
|
+
# Claude Code (finestra di compattazione - 20000 - 13000), non contro un 80% fisso:
|
|
18
|
+
# vedi il commento sopra il blocco CTX per le regole. CONTEXT_USABLE_RATIO resta
|
|
19
|
+
# solo come override manuale (0 < ratio <= 1: denominatore = tetto * ratio).
|
|
16
20
|
#
|
|
17
21
|
# Usage: % REALE del piano via l'API OAuth di Anthropic (come ccstatusline):
|
|
18
22
|
# GET https://api.anthropic.com/api/oauth/usage con il Bearer preso da
|
|
@@ -139,12 +143,24 @@ fi
|
|
|
139
143
|
# Fonte primaria (come ccstatusline): .context_window del JSON di Claude Code
|
|
140
144
|
# (context_window_size = tetto reale, current_usage = token). Fallback: ultimo
|
|
141
145
|
# usage del transcript + tabella modello. $CONTEXT_LIMIT forza il tetto.
|
|
142
|
-
# Denominatore =
|
|
143
|
-
#
|
|
146
|
+
# Denominatore = soglia di auto-compact di Claude Code:
|
|
147
|
+
# finestra di compattazione = min(tetto modello, primo valido tra
|
|
148
|
+
# $CLAUDE_CODE_AUTO_COMPACT_WINDOW, autoCompactWindow dei settings, tetto);
|
|
149
|
+
# nei settings (utente < progetto < progetto.local, vince il piu' specifico)
|
|
150
|
+
# modelSettings.<model id senza [..]>.autoCompactWindow batte autoCompactWindow;
|
|
151
|
+
# soglia = finestra - 20000 (output riservato, massimo) - 13000 (buffer), min 1;
|
|
152
|
+
# $CLAUDE_AUTOCOMPACT_PCT_OVERRIDE (0 < pct <= 100) puo' solo abbassarla:
|
|
153
|
+
# min(soglia, floor(finestra * pct / 100)).
|
|
154
|
+
# Con DISABLE_AUTO_COMPACT / DISABLE_COMPACT truthy il denominatore e' il tetto pieno.
|
|
155
|
+
# CONTEXT_USABLE_RATIO (0 < r <= 1), se impostato, e' un override manuale:
|
|
156
|
+
# denominatore = tetto * r, senza calcolo di compattazione.
|
|
144
157
|
CTX="$(
|
|
145
158
|
{ [ -n "$TRANSCRIPT" ] && [ -f "$TRANSCRIPT" ] && tail -n 300 "$TRANSCRIPT" 2>/dev/null | tac | grep -m1 'cache_read_input_tokens'; } | \
|
|
146
159
|
CTX_MODEL_ID="$(j '.model.id')" \
|
|
147
160
|
CTX_CW_SIZE="$(j '.context_window.context_window_size')" \
|
|
161
|
+
CTX_PROJECT_DIR="$(j '.workspace.project_dir')" \
|
|
162
|
+
CTX_CURRENT_DIR="$(j '.workspace.current_dir')" \
|
|
163
|
+
CTX_CWD="$(j '.cwd')" \
|
|
148
164
|
CTX_USAGE="$(printf '%s' "$input" | jq -c '.context_window.current_usage // empty' 2>/dev/null)" \
|
|
149
165
|
python3 -c '
|
|
150
166
|
import sys, os, json, re
|
|
@@ -192,16 +208,65 @@ if not limit:
|
|
|
192
208
|
if tok > limit and limit <= 200000: # auto-bump 1M solo nel fallback
|
|
193
209
|
limit = 1000000
|
|
194
210
|
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
211
|
+
def pos_num(x):
|
|
212
|
+
# numero finito > 0, altrimenti None (bool e stringhe non numeriche scartati)
|
|
213
|
+
if isinstance(x, bool) or x is None: return None
|
|
214
|
+
try: v = float(x)
|
|
215
|
+
except Exception: return None
|
|
216
|
+
return v if v == v and v not in (float("inf"),) and v > 0 else None
|
|
217
|
+
|
|
218
|
+
def truthy(name):
|
|
219
|
+
return (os.environ.get(name) or "").strip().lower() in ("1", "true", "yes", "on")
|
|
220
|
+
|
|
221
|
+
def load_settings():
|
|
222
|
+
# utente < progetto < progetto.local; file illeggibili/invalidi saltati
|
|
223
|
+
cfg = os.environ.get("CLAUDE_CONFIG_DIR") or os.path.join(os.path.expanduser("~"), ".claude")
|
|
224
|
+
proj = os.environ.get("CTX_PROJECT_DIR") or os.environ.get("CTX_CURRENT_DIR") or os.environ.get("CTX_CWD")
|
|
225
|
+
paths = [os.path.join(cfg, "settings.json")]
|
|
226
|
+
if proj:
|
|
227
|
+
paths += [os.path.join(proj, ".claude", "settings.json"),
|
|
228
|
+
os.path.join(proj, ".claude", "settings.local.json")]
|
|
229
|
+
out = []
|
|
230
|
+
for p in paths:
|
|
231
|
+
try:
|
|
232
|
+
d = json.load(open(p))
|
|
233
|
+
if isinstance(d, dict): out.append(d)
|
|
234
|
+
except Exception:
|
|
235
|
+
pass
|
|
236
|
+
return out # dal meno al piu prioritario
|
|
237
|
+
|
|
238
|
+
def setting_window(model):
|
|
239
|
+
files = load_settings()[::-1] # piu prioritario per primo
|
|
240
|
+
for d in files: # per-modello batte top-level: prima cerco il per-modello
|
|
241
|
+
ms = d.get("modelSettings")
|
|
242
|
+
v = pos_num((ms.get(model) or {}).get("autoCompactWindow")) if isinstance(ms, dict) and isinstance(ms.get(model), dict) else None
|
|
243
|
+
if v: return v
|
|
244
|
+
for d in files:
|
|
245
|
+
v = pos_num(d.get("autoCompactWindow"))
|
|
246
|
+
if v: return v
|
|
247
|
+
return None
|
|
248
|
+
|
|
249
|
+
def threshold_for(limit):
|
|
250
|
+
model = re.sub(r"\[[^\]]*\]\s*$", "", os.environ.get("CTX_MODEL_ID") or "")
|
|
251
|
+
w = pos_num(os.environ.get("CLAUDE_CODE_AUTO_COMPACT_WINDOW")) or setting_window(model) or limit
|
|
252
|
+
w = int(min(w, limit))
|
|
253
|
+
thr = max(1, w - 20000 - 13000)
|
|
254
|
+
p = pos_num(os.environ.get("CLAUDE_AUTOCOMPACT_PCT_OVERRIDE"))
|
|
255
|
+
if p is not None and p <= 100:
|
|
256
|
+
thr = max(1, min(thr, int(w * p // 100)))
|
|
257
|
+
return thr
|
|
258
|
+
|
|
259
|
+
ratio = pos_num(os.environ.get("CONTEXT_USABLE_RATIO"))
|
|
260
|
+
if ratio is not None and ratio <= 1:
|
|
261
|
+
denom = round(limit * ratio) # override manuale
|
|
262
|
+
elif truthy("DISABLE_AUTO_COMPACT") or truthy("DISABLE_COMPACT"):
|
|
263
|
+
denom = limit # niente compattazione: tetto pieno
|
|
264
|
+
else:
|
|
265
|
+
denom = threshold_for(limit)
|
|
266
|
+
pct = min(100, round(tok*100/denom)) if denom else 0
|
|
202
267
|
n = 10; filled = round(pct*n/100)
|
|
203
268
|
bar = "█"*filled + "░"*(n-filled)
|
|
204
|
-
# numeri REALI: token effettivi / tetto pieno; la percentuale
|
|
269
|
+
# numeri REALI: token effettivi / tetto pieno; la percentuale e contro la soglia di compattazione
|
|
205
270
|
print(f"[{bar}] {round(tok/1000)}k/{round(limit/1000)}k ({pct}%)")
|
|
206
271
|
' 2>/dev/null
|
|
207
272
|
)"
|
|
@@ -0,0 +1,318 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
// trailhead auto-merge helper (self-contained, ships in templates/ so it reaches every
|
|
4
|
+
// install channel). Merges a verified slot branch of an autonomous run into the local
|
|
5
|
+
// trunk (never a push), recovers an interrupted merge, and removes a merged slot. #286.
|
|
6
|
+
//
|
|
7
|
+
// CLI (every command runs against the MAIN checkout, which is on the trunk):
|
|
8
|
+
// node trailhead-auto-merge.js merge --repo <main-checkout> --ticket <n> [--trunk <name>]
|
|
9
|
+
// node trailhead-auto-merge.js recover --repo <main-checkout>
|
|
10
|
+
// node trailhead-auto-merge.js remove --repo <main-checkout> --ticket <n> [--trunk <name>]
|
|
11
|
+
//
|
|
12
|
+
// Trunk: --trunk, else the remote default branch (refs/remotes/origin/HEAD), else the
|
|
13
|
+
// first existing of main / master (the base-preflight rule); none -> error.
|
|
14
|
+
// Every git-state path (MERGE_HEAD, MERGE_MSG, rebase-merge, ...) is resolved with
|
|
15
|
+
// `git rev-parse --git-path`, never assumed under .git/.
|
|
16
|
+
//
|
|
17
|
+
// merge: preconditions first, each a distinct status with nothing touched:
|
|
18
|
+
// not-on-trunk HEAD of the main checkout is not refs/heads/<trunk>
|
|
19
|
+
// in-progress a merge / rebase / cherry-pick is already under way (run recover)
|
|
20
|
+
// dirty tracked changes in the main checkout (untracked files are fine)
|
|
21
|
+
// slot-dirty the branch's slot worktree has uncommitted tracked work
|
|
22
|
+
// error the branch does not exist, or the trunk cannot be resolved
|
|
23
|
+
// Then: already-merged when the tip is an ancestor of the trunk (an idempotent retry);
|
|
24
|
+
// otherwise `git merge --no-edit -m "chore(auto): merge trailhead/t<n>" -m "Refs: #<n>"`
|
|
25
|
+
// (fast-forward when possible, else a merge commit). The main checkout's
|
|
26
|
+
// .trailhead/session-ticket (never written or rewritten here) does not block a merge: a
|
|
27
|
+
// fast-forward creates no commit, so the commit-msg hook never runs; a true merge commit
|
|
28
|
+
// adds --no-verify only when the marker names a ticket other than the merged one (the
|
|
29
|
+
// hook would demand that ticket's Refs trailer, and the helper already builds the whole
|
|
30
|
+
// message). No marker, or the same ticket: the hook stays active.
|
|
31
|
+
// merged method ff|merge, from (trunk before), head (trunk after)
|
|
32
|
+
// conflict conflicting paths in `conflicts`; the merge is aborted
|
|
33
|
+
// merge-failed any other failure (hook refusal, untracked file in the way); aborted
|
|
34
|
+
// An abort that does not leave HEAD at its pre-merge commit with a clean tracked tree
|
|
35
|
+
// falls back to `git reset --merge <pre>` (safe: the preconditions guarantee a clean
|
|
36
|
+
// tracked tree); only when that fails too is the result error "main left mid-merge",
|
|
37
|
+
// the one case the caller must stop on. Never --force, never reset --hard.
|
|
38
|
+
// On merged / already-merged, `behind` lists every OTHER trailhead/t* worktree slot with
|
|
39
|
+
// trunk commits not in its branch ({ticket, path, behind}), informational.
|
|
40
|
+
//
|
|
41
|
+
// recover: no merge under way -> clean; a merge whose MERGE_MSG first line is
|
|
42
|
+
// "chore(auto): merge trailhead/t<n>" -> aborted (same abort / fallback), with `ticket`;
|
|
43
|
+
// anything else (a human's merge, a rebase, a cherry-pick) -> foreign, untouched.
|
|
44
|
+
//
|
|
45
|
+
// remove: only for a branch already merged into the trunk (else not-merged). A worktree
|
|
46
|
+
// slot is removed with `git worktree remove` (no --force: a dirty one is an error and
|
|
47
|
+
// stays) and the branch deleted with `git branch -d`; a clone slot is never deleted
|
|
48
|
+
// (kept). Idempotent: a worktree already gone or a branch already deleted is skipped.
|
|
49
|
+
// removed carries worktreeRemoved / branchDeleted (what THIS call did).
|
|
50
|
+
//
|
|
51
|
+
// One orchestrator runs merges one at a time. Two autonomous runs on the same main
|
|
52
|
+
// checkout are not supported (no lock file); the in-progress precondition keeps this
|
|
53
|
+
// helper from touching a merge it did not start.
|
|
54
|
+
//
|
|
55
|
+
// Prints exactly one JSON line on stdout: {"status":"...", ...}. Exit 0 whenever the
|
|
56
|
+
// status is a result (parked statuses included), 1 on status error, 2 on a usage error.
|
|
57
|
+
|
|
58
|
+
const { spawnSync } = require('child_process');
|
|
59
|
+
const nodeFs = require('fs');
|
|
60
|
+
const path = require('path');
|
|
61
|
+
|
|
62
|
+
function defaultGit(args) {
|
|
63
|
+
const env = { ...process.env, GIT_EDITOR: 'true', GIT_TERMINAL_PROMPT: '0' };
|
|
64
|
+
const r = spawnSync('git', args, { encoding: 'utf8', env, maxBuffer: 64 * 1024 * 1024 });
|
|
65
|
+
return { status: r.status === null ? 1 : r.status, stdout: r.stdout || '', stderr: r.stderr || (r.error ? String(r.error.message) : '') };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const firstLine = (s) => String(s || '').split('\n').find((l) => l.trim()) || '';
|
|
69
|
+
const branchOf = (n) => `trailhead/t${n}`;
|
|
70
|
+
const BRANCH_RE = /^refs\/heads\/trailhead\/t(\d+)$/;
|
|
71
|
+
const MERGE_SUBJECT = /^chore\(auto\): merge trailhead\/t(\d+)$/;
|
|
72
|
+
const IN_PROGRESS = ['MERGE_HEAD', 'rebase-merge', 'rebase-apply', 'CHERRY_PICK_HEAD'];
|
|
73
|
+
|
|
74
|
+
// --- args ---------------------------------------------------------------------
|
|
75
|
+
|
|
76
|
+
const FLAGS = { merge: ['--repo', '--ticket', '--trunk'], recover: ['--repo'], remove: ['--repo', '--ticket', '--trunk'] };
|
|
77
|
+
|
|
78
|
+
function parseArgs(argv) {
|
|
79
|
+
const cmd = argv[0];
|
|
80
|
+
if (!Object.prototype.hasOwnProperty.call(FLAGS, cmd)) return { error: `usage: expected merge, recover or remove, got: ${cmd === undefined ? '(nothing)' : cmd}` };
|
|
81
|
+
const o = { cmd, repo: null, ticket: null, trunk: null };
|
|
82
|
+
for (let i = 1; i < argv.length; i++) {
|
|
83
|
+
const flag = argv[i];
|
|
84
|
+
if (!FLAGS[cmd].includes(flag)) return { error: `usage: unknown argument: ${flag}` };
|
|
85
|
+
i++;
|
|
86
|
+
if (i >= argv.length) return { error: `usage: ${flag} requires a value` };
|
|
87
|
+
const v = argv[i];
|
|
88
|
+
if (flag === '--ticket') {
|
|
89
|
+
if (!/^[1-9]\d*$/.test(v)) return { error: `usage: --ticket must be a positive integer, got: ${v}` };
|
|
90
|
+
o.ticket = Number(v);
|
|
91
|
+
} else if (!v.trim()) {
|
|
92
|
+
return { error: `usage: ${flag} requires a non-empty value` };
|
|
93
|
+
} else if (flag === '--repo') {
|
|
94
|
+
o.repo = v;
|
|
95
|
+
} else {
|
|
96
|
+
o.trunk = v;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
if (!o.repo) return { error: `usage: ${cmd} requires --repo` };
|
|
100
|
+
if (cmd !== 'recover' && !o.ticket) return { error: `usage: ${cmd} requires --ticket` };
|
|
101
|
+
return o;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// --- git helpers --------------------------------------------------------------
|
|
105
|
+
|
|
106
|
+
// [{ path, branch|null }] from `git worktree list --porcelain`, main checkout first.
|
|
107
|
+
function parseWorktrees(text) {
|
|
108
|
+
const out = [];
|
|
109
|
+
for (const block of String(text).split(/\n\s*\n/)) {
|
|
110
|
+
let wt = null;
|
|
111
|
+
let branch = null;
|
|
112
|
+
for (const line of block.split('\n')) {
|
|
113
|
+
if (line.startsWith('worktree ')) wt = line.slice('worktree '.length);
|
|
114
|
+
else if (line.startsWith('branch ')) branch = line.slice('branch '.length);
|
|
115
|
+
}
|
|
116
|
+
if (wt) out.push({ path: wt, branch });
|
|
117
|
+
}
|
|
118
|
+
return out;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const realOr = (fs, p) => { try { return fs.realpathSync(p); } catch { return path.resolve(p); } };
|
|
122
|
+
|
|
123
|
+
// Absolute path of a git-state file of the main checkout (via --git-path).
|
|
124
|
+
function statePath(ctx, name) {
|
|
125
|
+
const r = ctx.G(['rev-parse', '--git-path', name]);
|
|
126
|
+
return r.status === 0 && r.stdout.trim() ? path.resolve(ctx.main, r.stdout.trim()) : null;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const stateExists = (ctx, name) => { const p = statePath(ctx, name); return p !== null && ctx.fs.existsSync(p); };
|
|
130
|
+
const inProgress = (ctx) => IN_PROGRESS.filter((n) => stateExists(ctx, n));
|
|
131
|
+
const head = (ctx) => ctx.G(['rev-parse', 'HEAD']).stdout.trim();
|
|
132
|
+
const trackedDirty = (ctx, dir) => ctx.git(['-C', dir || ctx.main, 'status', '--porcelain', '--untracked-files=no']).stdout.trim() !== '';
|
|
133
|
+
const hasRef = (ctx, ref) => ctx.G(['rev-parse', '--verify', '--quiet', ref]).status === 0;
|
|
134
|
+
const isAncestor = (ctx, a, b) => ctx.G(['merge-base', '--is-ancestor', a, b]).status === 0;
|
|
135
|
+
|
|
136
|
+
// Trunk name per the base-preflight rule, or null.
|
|
137
|
+
function detectTrunk(ctx, requested) {
|
|
138
|
+
if (requested) return requested;
|
|
139
|
+
const h = ctx.G(['symbolic-ref', '-q', '--short', 'refs/remotes/origin/HEAD']);
|
|
140
|
+
if (h.status === 0 && h.stdout.trim().startsWith('origin/')) return h.stdout.trim().slice('origin/'.length);
|
|
141
|
+
for (const name of ['main', 'master']) {
|
|
142
|
+
if (hasRef(ctx, `refs/heads/${name}`) || hasRef(ctx, `refs/remotes/origin/${name}`)) return name;
|
|
143
|
+
}
|
|
144
|
+
return null;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// The main checkout's state is exactly the pre-merge one: HEAD at pre, no merge, clean tracked tree.
|
|
148
|
+
const settled = (ctx, pre) => head(ctx) === pre && !stateExists(ctx, 'MERGE_HEAD') && !trackedDirty(ctx);
|
|
149
|
+
|
|
150
|
+
// Puts the main checkout back at `pre`. Abort first, reset --merge as the fallback.
|
|
151
|
+
function undoMerge(ctx, pre) {
|
|
152
|
+
if (stateExists(ctx, 'MERGE_HEAD')) ctx.G(['merge', '--abort']);
|
|
153
|
+
if (settled(ctx, pre)) return true;
|
|
154
|
+
ctx.G(['reset', '--merge', pre]);
|
|
155
|
+
return settled(ctx, pre);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const stuck = (ctx, fail) => fail('main left mid-merge: the merge could not be aborted; resolve or abort it by hand in ' + ctx.main);
|
|
159
|
+
|
|
160
|
+
// Slot worktrees other than `ticket`, with trunk commits their branch lacks.
|
|
161
|
+
function behindList(ctx, trunk, ticket) {
|
|
162
|
+
const wl = ctx.G(['worktree', 'list', '--porcelain']);
|
|
163
|
+
if (wl.status !== 0) return [];
|
|
164
|
+
const out = [];
|
|
165
|
+
for (const w of parseWorktrees(wl.stdout)) {
|
|
166
|
+
const m = w.branch && BRANCH_RE.exec(w.branch);
|
|
167
|
+
if (!m || Number(m[1]) === ticket || realOr(ctx.fs, w.path) === ctx.main || !ctx.fs.existsSync(w.path)) continue;
|
|
168
|
+
const c = ctx.G(['rev-list', '--count', `${branchOf(m[1])}..refs/heads/${trunk}`]);
|
|
169
|
+
const n = Number(c.stdout.trim());
|
|
170
|
+
if (c.status === 0 && Number.isInteger(n) && n > 0) out.push({ ticket: Number(m[1]), path: w.path, behind: n });
|
|
171
|
+
}
|
|
172
|
+
return out.sort((a, b) => a.ticket - b.ticket);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// The marker's ticket number, or null (absent, unreadable or unparseable: the hook ignores it too).
|
|
176
|
+
function markerTicket(ctx) {
|
|
177
|
+
try {
|
|
178
|
+
const raw = String(ctx.fs.readFileSync(path.join(ctx.main, '.trailhead', 'session-ticket'), 'utf8')).replace(/^\uFEFF/, '');
|
|
179
|
+
const m = raw.split('\n')[0].replace(/\r$/, '').match(/^#([1-9]\d*)(?:\s|$)/);
|
|
180
|
+
return m ? Number(m[1]) : null;
|
|
181
|
+
} catch { return null; }
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// --- merge --------------------------------------------------------------------
|
|
185
|
+
|
|
186
|
+
function mergeBranch(ctx, o) {
|
|
187
|
+
const { fail, emit } = ctx;
|
|
188
|
+
const branch = branchOf(o.ticket);
|
|
189
|
+
const ref = `refs/heads/${branch}`;
|
|
190
|
+
const trunk = detectTrunk(ctx, o.trunk);
|
|
191
|
+
if (!trunk) return fail('no trunk found (no remote default branch, main or master); pass --trunk');
|
|
192
|
+
if (!hasRef(ctx, `refs/heads/${trunk}`)) return fail(`trunk ${trunk} has no local branch`);
|
|
193
|
+
const result = (status, extra) => { emit({ status, ticket: o.ticket, branch, trunk, ...extra }); return 0; };
|
|
194
|
+
|
|
195
|
+
const sym = ctx.G(['symbolic-ref', '-q', 'HEAD']);
|
|
196
|
+
if (sym.stdout.trim() !== `refs/heads/${trunk}`) return result('not-on-trunk', { reason: `the main checkout is on ${sym.stdout.trim() || 'a detached HEAD'}, not ${trunk}` });
|
|
197
|
+
const busy = inProgress(ctx);
|
|
198
|
+
if (busy.length) return result('in-progress', { reason: `${busy.join(', ')} present in the main checkout; run recover first` });
|
|
199
|
+
if (trackedDirty(ctx)) return result('dirty', { reason: 'tracked changes in the main checkout' });
|
|
200
|
+
if (!hasRef(ctx, ref)) return fail(`branch ${branch} does not exist`);
|
|
201
|
+
|
|
202
|
+
const wl = ctx.G(['worktree', 'list', '--porcelain']);
|
|
203
|
+
const slot = wl.status === 0 ? parseWorktrees(wl.stdout).find((w) => w.branch === ref && realOr(ctx.fs, w.path) !== ctx.main) : null;
|
|
204
|
+
if (slot && ctx.fs.existsSync(slot.path) && trackedDirty(ctx, slot.path)) return result('slot-dirty', { path: slot.path, reason: `uncommitted tracked work in ${slot.path} would be left out of the merge` });
|
|
205
|
+
|
|
206
|
+
if (isAncestor(ctx, ref, `refs/heads/${trunk}`)) return result('already-merged', { head: head(ctx), behind: behindList(ctx, trunk, o.ticket) });
|
|
207
|
+
|
|
208
|
+
const pre = head(ctx);
|
|
209
|
+
// marker di un altro ticket: l'hook commit-msg rifiuterebbe il merge commit, lo saltiamo
|
|
210
|
+
const marker = markerTicket(ctx);
|
|
211
|
+
const skipHook = marker !== null && marker !== o.ticket;
|
|
212
|
+
const m = ctx.G(['merge', '--no-edit', ...(skipHook ? ['--no-verify'] : []), '-m', `chore(auto): merge ${branch}`, '-m', `Refs: #${o.ticket}`, branch]);
|
|
213
|
+
if (m.status === 0) {
|
|
214
|
+
if (!isAncestor(ctx, ref, 'HEAD')) return fail(`git merge reported success but ${branch} is not in the trunk`);
|
|
215
|
+
const method = ctx.G(['rev-parse', '--verify', '--quiet', 'HEAD^2']).status === 0 ? 'merge' : 'ff';
|
|
216
|
+
return result('merged', { method, from: pre, head: head(ctx), behind: behindList(ctx, trunk, o.ticket) });
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
const conflicts = ctx.G(['diff', '--name-only', '--diff-filter=U']).stdout.split('\n').filter(Boolean);
|
|
220
|
+
const reason = firstLine(m.stderr) || firstLine(m.stdout) || `git merge exited ${m.status}`;
|
|
221
|
+
if (!undoMerge(ctx, pre)) return stuck(ctx, fail);
|
|
222
|
+
if (conflicts.length > 0) return result('conflict', { conflicts });
|
|
223
|
+
return result('merge-failed', { reason });
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// --- recover ------------------------------------------------------------------
|
|
227
|
+
|
|
228
|
+
function recoverMerge(ctx) {
|
|
229
|
+
const { fail, emit } = ctx;
|
|
230
|
+
const busy = inProgress(ctx);
|
|
231
|
+
if (!busy.length) { emit({ status: 'clean' }); return 0; }
|
|
232
|
+
if (!busy.includes('MERGE_HEAD')) { emit({ status: 'foreign', reason: `${busy.join(', ')} in progress, not started by a run` }); return 0; }
|
|
233
|
+
let msg = '';
|
|
234
|
+
try { msg = String(ctx.fs.readFileSync(statePath(ctx, 'MERGE_MSG'), 'utf8')); } catch { /* unreadable: foreign */ }
|
|
235
|
+
const m = MERGE_SUBJECT.exec(firstLine(msg).trim());
|
|
236
|
+
if (!m) { emit({ status: 'foreign', reason: 'a merge not started by a run is in progress' }); return 0; }
|
|
237
|
+
if (!undoMerge(ctx, head(ctx))) return stuck(ctx, fail);
|
|
238
|
+
emit({ status: 'aborted', ticket: Number(m[1]), branch: branchOf(m[1]) });
|
|
239
|
+
return 0;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// --- remove -------------------------------------------------------------------
|
|
243
|
+
|
|
244
|
+
// True when dir is an independent checkout (its git dir lives inside it) on `branch`.
|
|
245
|
+
function cloneOnBranch(ctx, dir, branch) {
|
|
246
|
+
const g = ctx.git(['-C', dir, 'rev-parse', '--absolute-git-dir']);
|
|
247
|
+
if (g.status !== 0) return false;
|
|
248
|
+
const gitDir = realOr(ctx.fs, g.stdout.trim());
|
|
249
|
+
const root = realOr(ctx.fs, dir);
|
|
250
|
+
if (gitDir !== root && !gitDir.startsWith(root + path.sep)) return false;
|
|
251
|
+
const b = ctx.git(['-C', dir, 'symbolic-ref', '--quiet', 'HEAD']);
|
|
252
|
+
return b.status === 0 && b.stdout.trim() === `refs/heads/${branch}`;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
function removeSlot(ctx, o) {
|
|
256
|
+
const { fail, emit } = ctx;
|
|
257
|
+
const branch = branchOf(o.ticket);
|
|
258
|
+
const ref = `refs/heads/${branch}`;
|
|
259
|
+
const trunk = detectTrunk(ctx, o.trunk);
|
|
260
|
+
if (!trunk) return fail('no trunk found (no remote default branch, main or master); pass --trunk');
|
|
261
|
+
if (!hasRef(ctx, `refs/heads/${trunk}`)) return fail(`trunk ${trunk} has no local branch`);
|
|
262
|
+
|
|
263
|
+
const wl = ctx.G(['worktree', 'list', '--porcelain']);
|
|
264
|
+
if (wl.status !== 0) return fail(`git worktree list failed: ${firstLine(wl.stderr) || 'exit ' + wl.status}`);
|
|
265
|
+
const slot = parseWorktrees(wl.stdout).find((w) => w.branch === ref && realOr(ctx.fs, w.path) !== ctx.main);
|
|
266
|
+
|
|
267
|
+
if (!slot) {
|
|
268
|
+
const sibling = path.join(path.dirname(ctx.main), `${path.basename(ctx.main)}-t${o.ticket}`);
|
|
269
|
+
if (ctx.fs.existsSync(sibling) && cloneOnBranch(ctx, sibling, branch)) { emit({ status: 'kept', ticket: o.ticket, kind: 'clone', path: sibling, reason: 'a clone slot is never deleted' }); return 0; }
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
const branchExists = hasRef(ctx, ref);
|
|
273
|
+
if (branchExists && !isAncestor(ctx, ref, `refs/heads/${trunk}`)) {
|
|
274
|
+
emit({ status: 'not-merged', ticket: o.ticket, branch, reason: `${branch} is not merged into ${trunk}; nothing removed` });
|
|
275
|
+
return 0;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
let worktreeRemoved = false;
|
|
279
|
+
if (slot && ctx.fs.existsSync(slot.path)) {
|
|
280
|
+
const rm = ctx.G(['worktree', 'remove', slot.path]);
|
|
281
|
+
if (rm.status !== 0) return fail(`git worktree remove failed for ${slot.path}, kept: ${firstLine(rm.stderr) || 'exit ' + rm.status}`);
|
|
282
|
+
worktreeRemoved = true;
|
|
283
|
+
} else if (slot) {
|
|
284
|
+
return fail(`stale worktree of ${branch}: ${slot.path} is gone from disk but git still lists it; run git worktree prune and retry`);
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
let branchDeleted = false;
|
|
288
|
+
if (branchExists) {
|
|
289
|
+
const d = ctx.G(['branch', '-d', branch]);
|
|
290
|
+
if (d.status !== 0) return fail(`git branch -d ${branch} failed: ${firstLine(d.stderr) || 'exit ' + d.status}`);
|
|
291
|
+
branchDeleted = true;
|
|
292
|
+
}
|
|
293
|
+
emit({ status: 'removed', ticket: o.ticket, branch, worktreeRemoved, branchDeleted });
|
|
294
|
+
return 0;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
function main(argv, { git, stdout, stderr, fs } = {}) {
|
|
298
|
+
const out = stdout || process.stdout;
|
|
299
|
+
const err = stderr || process.stderr;
|
|
300
|
+
const o = parseArgs(argv || []);
|
|
301
|
+
if (o.error) { err.write(o.error + '\n'); return 2; }
|
|
302
|
+
const fsx = fs || nodeFs;
|
|
303
|
+
const emit = (obj) => out.write(JSON.stringify(obj) + '\n');
|
|
304
|
+
const fail = (reason, extra) => { emit({ status: 'error', reason, ...extra }); return 1; };
|
|
305
|
+
const run = git || defaultGit;
|
|
306
|
+
const mainDir = realOr(fsx, o.repo);
|
|
307
|
+
const ctx = { git: run, G: (args) => run(['-C', mainDir, ...args]), fs: fsx, main: mainDir, fail, emit };
|
|
308
|
+
if (ctx.G(['rev-parse', '--git-dir']).status !== 0) return fail(`${o.repo} is not a git repository`);
|
|
309
|
+
if (o.cmd === 'merge') return mergeBranch(ctx, o);
|
|
310
|
+
if (o.cmd === 'recover') return recoverMerge(ctx);
|
|
311
|
+
return removeSlot(ctx, o);
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
module.exports = { main, parseArgs, parseWorktrees };
|
|
315
|
+
|
|
316
|
+
if (require.main === module) {
|
|
317
|
+
process.exit(main(process.argv.slice(2), {}));
|
|
318
|
+
}
|