@marcomigozzi/trailhead 0.13.0 → 0.14.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.
Files changed (61) hide show
  1. package/README.it.md +28 -3
  2. package/README.md +38 -3
  3. package/bin/lib/codex-projection.js +16 -9
  4. package/bin/lib/codex-projection.test.js +39 -11
  5. package/bin/lib/install-verify.js +10 -5
  6. package/bin/lib/install-verify.test.js +51 -0
  7. package/bin/trailhead.js +37 -25
  8. package/bin/trailhead.test.js +162 -5
  9. package/package.json +4 -1
  10. package/plugins/trailhead/.claude-plugin/plugin.json +3 -2
  11. package/plugins/trailhead/commands/panel.md +10 -0
  12. package/plugins/trailhead/hooks/hooks.json +3 -18
  13. package/plugins/trailhead/hooks/lib/body-sources.js +795 -0
  14. package/plugins/trailhead/hooks/lib/gh-subcommand.js +92 -10
  15. package/plugins/trailhead/hooks/lib/gh-subcommand.test.js +55 -0
  16. package/plugins/trailhead/hooks/lib/mockup-link.js +21 -5
  17. package/plugins/trailhead/hooks/lib/shell-scan.js +496 -64
  18. package/plugins/trailhead/hooks/lib/shell-scan.test.js +420 -0
  19. package/plugins/trailhead/hooks/lib/unmodelled.js +345 -0
  20. package/plugins/trailhead/hooks/lib/unmodelled.test.js +173 -0
  21. package/plugins/trailhead/hooks/lib/work-state.test.js +252 -0
  22. package/plugins/trailhead/hooks/mod/README.md +187 -0
  23. package/plugins/trailhead/hooks/mod/map-pane-view.ts +453 -0
  24. package/plugins/trailhead/hooks/mod/map-pane.tsx +663 -0
  25. package/plugins/trailhead/hooks/mod/register.tsx +280 -0
  26. package/plugins/trailhead/hooks/mod/work-state-prompt.ts +69 -0
  27. package/plugins/trailhead/hooks/mod/work-state-view.ts +93 -0
  28. package/plugins/trailhead/hooks/trailhead-bash-guards.js +140 -0
  29. package/plugins/trailhead/hooks/trailhead-bash-guards.test.js +233 -0
  30. package/plugins/trailhead/hooks/trailhead-body-guard.js +145 -61
  31. package/plugins/trailhead/hooks/trailhead-body-guard.test.js +224 -3
  32. package/plugins/trailhead/hooks/trailhead-commit-guard.js +41 -14
  33. package/plugins/trailhead/hooks/trailhead-commit-guard.test.js +2 -1
  34. package/plugins/trailhead/hooks/trailhead-install-guard.js +22 -12
  35. package/plugins/trailhead/hooks/trailhead-install-guard.test.js +2 -1
  36. package/plugins/trailhead/hooks/trailhead-mockup-link-guard.js +48 -11
  37. package/plugins/trailhead/hooks/trailhead-mockup-link-guard.test.js +75 -3
  38. package/plugins/trailhead/hooks/trailhead-search-guard.js +19 -9
  39. package/plugins/trailhead/hooks/trailhead-search-guard.test.js +2 -1
  40. package/plugins/trailhead/hooks/trailhead-secret-guard.js +541 -57
  41. package/plugins/trailhead/hooks/trailhead-secret-guard.test.js +1000 -4
  42. package/plugins/trailhead/hooks/trailhead-secret-read-guard.js +171 -6
  43. package/plugins/trailhead/hooks/trailhead-secret-read-guard.test.js +170 -0
  44. package/plugins/trailhead/skills/_shared/choices.md +3 -1
  45. package/plugins/trailhead/skills/_shared/multi-map.md +1 -1
  46. package/plugins/trailhead/skills/_shared/session-handoff.md +1 -1
  47. package/plugins/trailhead/skills/_shared/teamwork.md +4 -2
  48. package/plugins/trailhead/skills/_shared/techniques/prototype.md +1 -1
  49. package/plugins/trailhead/skills/trailhead/SKILL.md +2 -2
  50. package/plugins/trailhead/skills/trailhead-work/SKILL.md +3 -2
  51. package/plugins/trailhead/skills/trailhead-work/references/auto.md +2 -0
  52. package/plugins/trailhead/skills/trailhead-work/references/ticket-engines.md +3 -1
  53. package/plugins/trailhead/templates/trailhead-work-state.js +199 -0
  54. package/plugins/trailhead/tests/map-pane-ui.test.ts +1374 -0
  55. package/plugins/trailhead/tests/map-pane-view.test.ts +565 -0
  56. package/plugins/trailhead/tests/mod.test.ts +14 -0
  57. package/plugins/trailhead/tests/work-state-prompt-hook.test.ts +207 -0
  58. package/plugins/trailhead/tests/work-state-prompt.test.ts +109 -0
  59. package/plugins/trailhead/tests/work-state-ui.test.ts +365 -0
  60. package/plugins/trailhead/tests/work-state-view.test.ts +86 -0
  61. package/plugins/trailhead/types/index.d.ts +69 -0
@@ -6,6 +6,8 @@ Every ticket is **HITL** (human in the loop, speaking for themselves) or **AFK**
6
6
 
7
7
  Technique names below (**Grilling**, **Domain vocabulary**, **Prototype**, **Research**, **TDD**, **Code review**, **Acceptance testing**, **Debug**) are indexed in `../../_shared/techniques.md`; their full protocols live in `_shared/`, read from `../../_shared/techniques/<name>.md`.
8
8
 
9
+ **Work state (every type).** Keep `.trailhead/work-state.json` current with the `set --step` call in the **Work state file** paragraph of `../../_shared/teamwork.md`: `build` Discuss/Plan/Execute/Verify/Resolve set `discuss`/`plan`/`execute`/`verify`/`resolve`; `bug` Repro and Diagnose set `discuss`, Fix `execute`, Verify `verify`, Resolve `resolve`; `decision`, `prototype` and `task` stay `discuss` until `resolve`. A loop back from Verify to Execute (or Fix) sets `execute` again, and a UAT follow-up (`UAT of:`) sets `verify`. `research` tickets never write it: they run AFK and possibly in parallel, and the single file tracks only the one ticket in play.
10
+
9
11
  ## UAT follow-ups (any type)
10
12
  A ticket whose body carries `UAT of: <parent>` is a UAT follow-up, **whatever its type label**, including a graduated seed (a `trailhead:seed` that dropped `trailhead:seed` + `trailhead:blocked` on graduating, keeping whatever type label it had, or none). `work`, `quick`, and `auto` all dispatch through this file, so this check runs **before** the per-type engines below. It resolves via the **Acceptance testing** technique (`../../_shared/techniques/acceptance-testing.md`), starting with the drive-mode ask; the record is a `VERIFY` comment on the follow-up itself, never on the parent. It closes only on a confirmed pass, with a resolution comment linking back to the parent.
11
13
 
@@ -30,7 +32,7 @@ The ticket that *builds*. Lean cycle, all in **ticket comments**:
30
32
 
31
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`.
32
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). 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
- 3. **Execute**: **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), 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. **Once the mockup is approved, reproduce it with 100% fidelity** (layout, spacing, sizing, colour, type, copy, and every state), never a loose approximation "in the spirit of" it; if part of the approved mockup is genuinely infeasible, stop and raise it rather than silently deviate (full statement in the **Prototype** technique, `../../_shared/techniques/prototype.md`). **Make fidelity verifiable, not a hope:** before finishing Execute, enumerate the approved mockup's elements and states, confirm each is reproduced, and **record that enumerated fidelity list as a ticket comment** (noting any element raised as infeasible with its resolution), so Verify works from a durable checklist instead of reconstructing intent. At Verify, **Acceptance testing** checks the built screen against the approved mockup (element by element, state by state) and **Goal-backward verification** carries fidelity as an explicit goal claim (see `../../_shared/techniques/acceptance-testing.md` and `../../_shared/techniques/verify.md`). Then implement with **atomic commits** (conventional commits, each with a `Refs: #<n>` trailer for this ticket, see **Principles** in `../../_shared/principles.md`; the trailer is also what scopes Verify's review and goal check, per **Ticket diff** in `../../_shared/techniques/code-review.md`), following the repo's `trailhead:conventions` `git:`, straight to `main` by default, or a feature branch + PR when `git: pr`. **Under `isolation: worktree`, do the work in this ticket's `git worktree`** (`trailhead/t<n>` branch), integrating to the trunk at Resolve (see `../../_shared/teamwork.md`). One commit = one verifiable step. Run this via an **executor subagent on `config.models.execute`** (dispatch the **`trailhead-executor`** agent, full tools that can Edit/Write/commit, passing `config.models.execute` as the model override; inline when the model is unset or equals the session model); the commits land on `main`, visible. **Before installing any new dependency, run the verify-before-install gate** (slopsquatting barrier): check the package's legitimacy (name / typosquat similarity to popular packages, age / first-seen date, download counts, repository metadata present and matching) and **stop and ask the user on a suspicious result** (block-and-ask), never installing on your own judgement. On a host with a hook bus the `trailhead-install-guard` `PreToolUse` hook enforces this, blocking a named public-registry install (npm / PyPI / crates.io / Go / RubyGems) until it is vetted; once vetted, proceed by re-running the exact install command prefixed with `TRAILHEAD_VERIFIED_INSTALL=1`. New scope surfacing mid-execute is captured/split, not worked here.
35
+ 3. **Execute**: **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. **Once the mockup is approved, reproduce it with 100% fidelity** (layout, spacing, sizing, colour, type, copy, and every state), never a loose approximation "in the spirit of" it; if part of the approved mockup is genuinely infeasible, stop and raise it rather than silently deviate (full statement in the **Prototype** technique, `../../_shared/techniques/prototype.md`). **Make fidelity verifiable, not a hope:** before finishing Execute, enumerate the approved mockup's elements and states, confirm each is reproduced, and **record that enumerated fidelity list as a ticket comment** (noting any element raised as infeasible with its resolution), so Verify works from a durable checklist instead of reconstructing intent. At Verify, **Acceptance testing** checks the built screen against the approved mockup (element by element, state by state) and **Goal-backward verification** carries fidelity as an explicit goal claim (see `../../_shared/techniques/acceptance-testing.md` and `../../_shared/techniques/verify.md`). Then implement with **atomic commits** (conventional commits, each with a `Refs: #<n>` trailer for this ticket, see **Principles** in `../../_shared/principles.md`; the trailer is also what scopes Verify's review and goal check, per **Ticket diff** in `../../_shared/techniques/code-review.md`), following the repo's `trailhead:conventions` `git:`, straight to `main` by default, or a feature branch + PR when `git: pr`. **Under `isolation: worktree`, do the work in this ticket's `git worktree`** (`trailhead/t<n>` branch), integrating to the trunk at Resolve (see `../../_shared/teamwork.md`). One commit = one verifiable step. Run this via an **executor subagent on `config.models.execute`** (dispatch the **`trailhead-executor`** agent, full tools that can Edit/Write/commit, passing `config.models.execute` as the model override; inline when the model is unset or equals the session model); the commits land on `main`, visible. **Before installing any new dependency, run the verify-before-install gate** (slopsquatting barrier): check the package's legitimacy (name / typosquat similarity to popular packages, age / first-seen date, download counts, repository metadata present and matching) and **stop and ask the user on a suspicious result** (block-and-ask), never installing on your own judgement. On a host with a hook bus the `trailhead-install-guard` `PreToolUse` hook enforces this, blocking a named public-registry install (npm / PyPI / crates.io / Go / RubyGems) until it is vetted; once vetted, proceed by re-running the exact install command prefixed with `TRAILHEAD_VERIFIED_INSTALL=1`. New scope surfacing mid-execute is captured/split, not worked here.
34
36
  4. **Verify**: a `VERIFY` comment: 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`); **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 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: 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.
35
37
  5. **Resolve**: a resolution comment with what was done → `gh issue close` → update `Decisions so far` on the map (a body read-modify-write: obey 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). **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 (opens with 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 **only on a confirmed pass** (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, 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`) in place (a line or two, not a re-map). Then close the session with the **Session handoff** ritual (`../../_shared/session-handoff.md`): `/clear` + next command.
36
38
 
@@ -0,0 +1,199 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+ // trailhead work state (self-contained, ships in templates/ so it reaches every
4
+ // install channel: bin/trailhead.js copies templates/ wholesale for Claude/npm
5
+ // and Codex). Writes and removes <repo>/.trailhead/work-state.json, the state
6
+ // of the ticket in play that the skills update at every step. #205.
7
+ //
8
+ // CLI:
9
+ // node trailhead-work-state.js set [--repo <dir>] [--map <n>|none] [--ticket <n>]
10
+ // [--title <t>] [--step <step>] [--auto true|false] [--auto-done <n>]
11
+ // node trailhead-work-state.js clear [--repo <dir>]
12
+ // --repo defaults to cwd. `set` merges the given fields onto the existing file
13
+ // (only --step at each step change); a --ticket other than the one on file
14
+ // starts from an empty state. File shape:
15
+ // { map, ticket, title, step, auto, autoDone, session, updatedAt }
16
+ // autoDone: tickets resolved so far in the autonomous run (non-negative
17
+ // integer, default 0; reset to 0 whenever auto is false).
18
+ // Prints one JSON line {"status","path","state"?,"message"?}.
19
+ // status: written | cleared | absent | skipped
20
+ // Exit 0 on every normal outcome and on any unexpected I/O error (it must
21
+ // never block the caller: status skipped with the message); exit 2 only on a
22
+ // usage or validation error (nothing is written).
23
+
24
+ const fs = require('fs');
25
+ const path = require('path');
26
+ const crypto = require('crypto');
27
+
28
+ const STATE_DIR = '.trailhead';
29
+ const STATE_FILE = 'work-state.json';
30
+ const STEPS = ['discuss', 'plan', 'execute', 'verify', 'resolve', 'paused'];
31
+ const TITLE_MAX = 200;
32
+
33
+ const isPosInt = (v) => Number.isInteger(v) && v > 0;
34
+
35
+ // Strict parse of a positive integer from a CLI string; null if invalid.
36
+ function parsePosInt(text) {
37
+ return /^[1-9]\d*$/.test(text) ? Number(text) : null;
38
+ }
39
+
40
+ // Strict parse of a non-negative integer from a CLI string; null if invalid.
41
+ function parseNonNegInt(text) {
42
+ return /^(0|[1-9]\d*)$/.test(text) ? Number(text) : null;
43
+ }
44
+
45
+ // Title on a single line: control characters and newlines become spaces,
46
+ // spaces collapsed, trimmed, capped at TITLE_MAX characters.
47
+ function cleanTitle(text) {
48
+ return String(text).replace(/[\u0000-\u001f\u007f\s]+/g, ' ').trim().slice(0, TITLE_MAX);
49
+ }
50
+
51
+ // Keeps only the valid fields of an arbitrary object: an invalid field is
52
+ // dropped (treated as absent), never carried into the new state.
53
+ function sanitizeState(raw) {
54
+ const out = {};
55
+ if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) return out;
56
+ if (raw.map === null || isPosInt(raw.map)) out.map = raw.map;
57
+ if (isPosInt(raw.ticket)) out.ticket = raw.ticket;
58
+ if (typeof raw.title === 'string') out.title = cleanTitle(raw.title);
59
+ if (STEPS.includes(raw.step)) out.step = raw.step;
60
+ if (typeof raw.auto === 'boolean') out.auto = raw.auto;
61
+ if (Number.isInteger(raw.autoDone) && raw.autoDone >= 0) out.autoDone = raw.autoDone;
62
+ return out;
63
+ }
64
+
65
+ // Merges the patch onto the existing state. A different ticket starts from
66
+ // empty (no inherited title/map). Returns the normalised state, or
67
+ // { error } when ticket or step is missing.
68
+ function mergeState(existing, patch, { session = null, now = new Date() } = {}) {
69
+ const base = sanitizeState(existing);
70
+ const start = patch.ticket !== undefined && base.ticket !== undefined && base.ticket !== patch.ticket ? {} : base;
71
+ const merged = { ...start, ...patch };
72
+ if (merged.ticket === undefined) return { error: 'usage: set needs a ticket (pass --ticket)' };
73
+ if (merged.step === undefined) return { error: 'usage: set needs a step (pass --step)' };
74
+ const auto = merged.auto === undefined ? false : merged.auto;
75
+ return {
76
+ map: merged.map === undefined ? null : merged.map,
77
+ ticket: merged.ticket,
78
+ title: merged.title === undefined || merged.title === '' ? null : merged.title,
79
+ step: merged.step,
80
+ auto,
81
+ // A run that is not autonomous has no counter.
82
+ autoDone: auto && merged.autoDone !== undefined ? merged.autoDone : 0,
83
+ session,
84
+ updatedAt: now.toISOString(),
85
+ };
86
+ }
87
+
88
+ function parseArgs(argv) {
89
+ const command = argv[0];
90
+ if (command !== 'set' && command !== 'clear') {
91
+ return { error: `usage: expected "set" or "clear", got: ${command === undefined ? '(nothing)' : command}` };
92
+ }
93
+ let repo = null;
94
+ const patch = {};
95
+ const allowed = command === 'set' ? ['--repo', '--map', '--ticket', '--title', '--step', '--auto', '--auto-done'] : ['--repo'];
96
+ for (let i = 1; i < argv.length; i++) {
97
+ const flag = argv[i];
98
+ if (!allowed.includes(flag)) return { error: `usage: unknown argument: ${flag}` };
99
+ i++;
100
+ if (i >= argv.length) return { error: `usage: ${flag} requires a value` };
101
+ const value = argv[i];
102
+ if (flag === '--repo') repo = value;
103
+ else if (flag === '--map') {
104
+ if (value === 'none') patch.map = null;
105
+ else if (parsePosInt(value) !== null) patch.map = parsePosInt(value);
106
+ else return { error: `usage: --map must be a positive integer or "none", got: ${value}` };
107
+ } else if (flag === '--ticket') {
108
+ if (parsePosInt(value) === null) return { error: `usage: --ticket must be a positive integer, got: ${value}` };
109
+ patch.ticket = parsePosInt(value);
110
+ } else if (flag === '--title') patch.title = cleanTitle(value);
111
+ else if (flag === '--step') {
112
+ if (!STEPS.includes(value)) return { error: `usage: --step must be one of ${STEPS.join(', ')}, got: ${value}` };
113
+ patch.step = value;
114
+ } else if (flag === '--auto') {
115
+ if (value !== 'true' && value !== 'false') return { error: `usage: --auto must be true or false, got: ${value}` };
116
+ patch.auto = value === 'true';
117
+ } else if (flag === '--auto-done') {
118
+ if (parseNonNegInt(value) === null) return { error: `usage: --auto-done must be a non-negative integer, got: ${value}` };
119
+ patch.autoDone = parseNonNegInt(value);
120
+ }
121
+ }
122
+ return { command, repo, patch };
123
+ }
124
+
125
+ // Reads the existing file; missing or corrupt = no state (never a crash).
126
+ function readExisting(file, fsMod) {
127
+ try {
128
+ return JSON.parse(fsMod.readFileSync(file, 'utf8'));
129
+ } catch {
130
+ return null;
131
+ }
132
+ }
133
+
134
+ // Atomic write: a unique temp in the same dir, then rename over the file
135
+ // (never an in-place truncate); the temp is removed if anything fails.
136
+ function writeAtomic(dir, file, text, fsMod) {
137
+ fsMod.mkdirSync(dir, { recursive: true });
138
+ const tmp = path.join(dir, `.${STATE_FILE}.${process.pid}-${crypto.randomBytes(4).toString('hex')}.tmp`);
139
+ try {
140
+ fsMod.writeFileSync(tmp, text);
141
+ fsMod.renameSync(tmp, file);
142
+ } catch (err) {
143
+ try { fsMod.unlinkSync(tmp); } catch { /* pulizia best effort */ }
144
+ throw err;
145
+ }
146
+ }
147
+
148
+ function writeResult(stdout, result) {
149
+ stdout.write(JSON.stringify(result) + '\n');
150
+ }
151
+
152
+ function main(argv, { cwd, stdout, env, fs: fsMod, now } = {}) {
153
+ const cwd_ = cwd || process.cwd();
154
+ const out = stdout || process.stdout;
155
+ const env_ = env || process.env;
156
+ const fs_ = fsMod || fs;
157
+
158
+ const parsed = parseArgs(argv || []);
159
+ if (parsed.error) {
160
+ process.stderr.write(parsed.error + '\n');
161
+ return 2;
162
+ }
163
+
164
+ const repoDir = parsed.repo ? path.resolve(cwd_, parsed.repo) : cwd_;
165
+ const dir = path.join(repoDir, STATE_DIR);
166
+ const file = path.join(dir, STATE_FILE);
167
+ try {
168
+ if (parsed.command === 'clear') {
169
+ if (!fs_.existsSync(file)) {
170
+ writeResult(out, { status: 'absent', path: file });
171
+ return 0;
172
+ }
173
+ fs_.unlinkSync(file);
174
+ writeResult(out, { status: 'cleared', path: file });
175
+ return 0;
176
+ }
177
+
178
+ const sessionEnv = env_.CLAUDE_CODE_SESSION_ID;
179
+ const session = typeof sessionEnv === 'string' && sessionEnv !== '' ? sessionEnv : null;
180
+ const state = mergeState(readExisting(file, fs_), parsed.patch, { session, now: now || new Date() });
181
+ if (state.error) {
182
+ process.stderr.write(state.error + '\n');
183
+ return 2;
184
+ }
185
+ writeAtomic(dir, file, JSON.stringify(state, null, 2) + '\n', fs_);
186
+ writeResult(out, { status: 'written', path: file, state });
187
+ return 0;
188
+ } catch (err) {
189
+ writeResult(out, { status: 'skipped', path: file, message: 'error: ' + (err && err.message ? err.message : String(err)) });
190
+ return 0;
191
+ }
192
+ }
193
+
194
+ module.exports = { STEPS, sanitizeState, mergeState, parseArgs, main };
195
+
196
+ if (require.main === module) {
197
+ const code = main(process.argv.slice(2), { cwd: process.cwd(), stdout: process.stdout, env: process.env });
198
+ process.exit(code);
199
+ }