@skitterbyte/skitterspec 22.3.0 → 22.3.2

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.
@@ -188,6 +188,7 @@ through to a documented conservative default; see the field notes below.
188
188
  // deliberate: the file it names then belongs to some other spec, and the safe
189
189
  // failure is a refusal you clear with /commit, not a stranger's file swept
190
190
  // into your commit. Default: none — a spec owns only its own folder.
191
+
191
192
  "spec": {
192
193
  "companionPaths": []
193
194
  },
@@ -1044,10 +1044,43 @@ a.ticket:hover { border-color: var(--accent); }
1044
1044
  return bands
1045
1045
  }
1046
1046
 
1047
+ // --- one order, for both columns -----------------------------------------
1048
+ // The sidebar and the panes are two views of one list, and they must be the
1049
+ // SAME list in the same order — a reader scrolling the panes watches the
1050
+ // current-file mark walk the sidebar, and a mark that walks backwards is the
1051
+ // whole reason this exists.
1052
+ //
1053
+ // The emitted order cannot be it. `review.js` collects tracked changes and
1054
+ // untracked files in two passes, so a new file is appended after everything
1055
+ // else however its path sorts; git's own order is not the tree's either.
1056
+ //
1057
+ // So the tree's order is the one order, settled here and used by both. It is
1058
+ // expressed as a comparator rather than derived from the built tree because
1059
+ // the panes are built long before the tree is, and a second traversal that
1060
+ // had to agree with the first is the thing that went wrong.
1061
+ function treeOrder(a, b) {
1062
+ var A = a.split('/')
1063
+ var B = b.split('/')
1064
+ var n = Math.min(A.length, B.length)
1065
+ for (var i = 0; i < n; i++) {
1066
+ // A segment with anything after it is a directory. `renderTree` walks
1067
+ // every directory at a level — label and subtree — before that level's
1068
+ // files, so a directory sorts ahead of a file it ties with by name.
1069
+ var aDir = i < A.length - 1
1070
+ var bDir = i < B.length - 1
1071
+ if (aDir !== bDir) return aDir ? -1 : 1
1072
+ if (A[i] !== B[i]) return A[i].localeCompare(B[i])
1073
+ }
1074
+ return 0
1075
+ }
1076
+
1077
+ // A copy: `data` is the emitted review, and the page is a view of it.
1078
+ var files = data.files.slice().sort(function (a, b) { return treeOrder(a.path, b.path) })
1079
+
1047
1080
  // --- per-file page state -------------------------------------------------
1048
1081
  // Band state lives HERE, never in the emitted data: the page is a view of an
1049
1082
  // immutable review, and expanding context is a property of this reading of it.
1050
- var state = data.files.map(function (f) {
1083
+ var state = files.map(function (f) {
1051
1084
  var rows = parsePatch(f.patch)
1052
1085
  var st = {
1053
1086
  file: f,
@@ -1717,7 +1750,16 @@ a.ticket:hover { border-color: var(--accent); }
1717
1750
  function renderTree(node) {
1718
1751
  var ul = el('ul')
1719
1752
  ;(function walk(n, depth) {
1720
- Object.keys(n.dirs).sort().forEach(function (name) {
1753
+ // `treeOrder` above is the authority and these two sorts must match it,
1754
+ // which is why the directories sort by `localeCompare` rather than by the
1755
+ // default `.sort()`: that compares UTF-16 code units, so it puts every
1756
+ // capitalised name ahead of every lowercase one and the panes would drift
1757
+ // from the sidebar again on the first `README/` in a diff.
1758
+ //
1759
+ // Insertion order would be free — the list arrives sorted — but a key
1760
+ // that looks like an array index (`2024/`) is enumerated first whatever
1761
+ // order it was inserted in, and a dated directory is not exotic.
1762
+ Object.keys(n.dirs).sort(function (a, b) { return a.localeCompare(b) }).forEach(function (name) {
1721
1763
  var li = el('li')
1722
1764
  li.appendChild(indent(el('span', 'dir', name + '/'), depth, 0.6))
1723
1765
  ul.appendChild(li)
@@ -1750,9 +1792,9 @@ a.ticket:hover { border-color: var(--accent); }
1750
1792
  }
1751
1793
 
1752
1794
  var tree = document.getElementById('tree')
1753
- tree.appendChild(renderTree(buildTree(data.files)))
1795
+ tree.appendChild(renderTree(buildTree(files)))
1754
1796
  document.getElementById('tree-summary').textContent =
1755
- 'Files (' + data.files.length + ')'
1797
+ 'Files (' + files.length + ')'
1756
1798
  if (window.matchMedia && !window.matchMedia('(min-width: 60rem)').matches) {
1757
1799
  document.getElementById('tree-wrap').open = false
1758
1800
  }
@@ -47,21 +47,49 @@ bug's **own branch**, never directly on `main`. Provision the worktree **now**,
47
47
  before the failing test, so the test, the fix, and the spec all land together and
48
48
  arrive as one reviewable PR.
49
49
 
50
- The engine resolves a spec by its folder, so seed a **minimal stub** for it to
51
- provision from — you'll flesh it out in §4:
52
-
53
- - From the base branch (`main`), create
54
- `specs/in-progress/bug-<name>/00-overview.md` with just the header block and the
55
- `## Symptom` you established above.
56
- - Run `skitterspec spec-env up bug-<name>` (the `spec-env` CLI engine). It prints
57
- the `git worktree add … -b bug/<name>` command (a branch forked from `main`),
58
- the worktree path, and any `in the worktree, run:` bootstrap steps.
59
- - Run the printed commands in order. The plan **commits the stub first** — the
60
- worktree forks from `main`'s last commit, so the stub has to be in it — and
61
- then adds the worktree. Nothing to move afterwards: the spec is already there.
62
- The commit is planned, not silent; it appears in the printed plan above the
63
- `git worktree add`, and `spec-env up` refuses outright if anything *other* than
64
- this spec is uncommitted.
50
+ **Provision before you write, not after.** §1 put nothing on disk and the bug's
51
+ name is settled by the end of it, so this is the one moment where a worktree
52
+ costs nothing to obtain and everything — the red test, the fix, the spec — is
53
+ still ahead of it:
54
+
55
+ ```
56
+ skitterspec spec-env up bug-<name> --docs
57
+ ```
58
+
59
+ **The spec does not exist yet, and that is the lane.** `up` opens its authoring
60
+ branch on exactly three signals — a spec it could not find, `--docs`, and a name
61
+ shaped `bug-<something>` — records the branch so the name resolves from here on,
62
+ and prints a `git worktree add … -b bug/<name>` forked from `main`. Run what it
63
+ prints, then move this session into the worktree with a plain `cd`; the Bash
64
+ working directory persists between calls, so from here on the test, the fix and
65
+ the spec are all written on the bug's own branch. Confirm the move landed rather
66
+ than reading silence as success (`.claude/rules/negative-checks.md` rule 1):
67
+ `skitterspec spec-env resolve` with no argument must name this spec. If it does
68
+ not, say so and stop rather than writing into a tree you cannot name.
69
+
70
+ **Then run `up` again, without the flag:**
71
+
72
+ ```
73
+ skitterspec spec-env up bug-<name>
74
+ ```
75
+
76
+ `--docs` skips the `setup` commands — everything whose only purpose is making a
77
+ tree *runnable* — because provisioning somewhere to write markdown should cost a
78
+ `git worktree add` and nothing else. `/spec` leaves them to `/spec-start`, but
79
+ **there is no `/spec-start` in a bug's life**: §3 runs the project's suite three
80
+ steps from here, in this same tree, and
81
+ **a tree that cannot run the suite cannot go red**.
82
+ The flagless re-run re-attaches the worktree that now exists
83
+ (no second `-b` fork) and prints the bootstrap steps the flag deferred.
84
+
85
+ **Why the stub is no longer seeded on the base branch.** This step used to open
86
+ by writing `specs/in-progress/bug-<name>/00-overview.md` in the primary checkout
87
+ for the engine to resolve, and `main` is where work **lands**, not where it
88
+ happens: where the project installs `.claude/hooks/main-guard.cjs` that write is
89
+ refused, and the skill's documented first instruction becomes the one thing the
90
+ repo will not do. Nothing is seeded and nothing is moved now — the spec is
91
+ written in the worktree, at §4, on the branch it belongs to.
92
+
65
93
  - **Bootstrap the worktree.** A fresh worktree has no installed dependencies and
66
94
  none of the repo's gitignored files (`.env`, local overrides). Run the printed
67
95
  `in the worktree, run:` steps (file seeding, then `setup`) in order, before
@@ -71,9 +99,10 @@ provision from — you'll flesh it out in §4:
71
99
  — run `/add-dir <trusted root>` before editing into the worktree, or the first
72
100
  edits will prompt.
73
101
  - **Do everything below in the worktree**, on the branch — the red test, the fix,
74
- and the rest of the spec. Act on the worktree with absolute paths /
75
- `git -C <worktreePath>`, or open a session rooted at the printed path. `main`
76
- changes only when the branch merges (at `/spec-complete`).
102
+ and the rest of the spec. The `cd` above is what makes that the default rather
103
+ than something to remember; where a step has to reach out of the tree, use an
104
+ absolute path or `git -C <worktreePath>`. `main` changes only when the branch
105
+ merges (at `/spec-complete`).
77
106
 
78
107
  ## 3. Write the failing test FIRST (RED) — mandatory
79
108
 
@@ -89,8 +118,10 @@ Encode the **correct** (expected) behaviour as a test, then run it and confirm i
89
118
 
90
119
  ## 4. Write the Bug spec
91
120
 
92
- Fill in the spec's entry point `00-overview.md`. **When isolated**, you already
93
- seeded this stub in §2 and moved it into the worktree — flesh it out there.
121
+ Write the spec's entry point `00-overview.md`. **When isolated**, you are already
122
+ standing in this spec's own worktree — §2 provisioned it and moved you there, so
123
+ create the folder and write it here, on the bug's branch. Nothing was seeded
124
+ ahead of it and nothing is moved across.
94
125
  **When not isolated**, create the spec **folder**
95
126
  `specs/in-progress/bug-<kebab-name>/` with its entry point `00-overview.md` now
96
127
  (every spec is a folder — never a bare file). A bug is
@@ -55,39 +55,69 @@ Hotfixes are concrete — confirm, don't over-grill. Establish:
55
55
  - **Root cause:** read the code **at the base tag** and trace it to `file:line`.
56
56
  The fix belongs on the tag's line, so reason about that code, not `main`'s.
57
57
 
58
- ## 3. Seed the stub, then provision the worktree from the tag
59
-
60
- The engine forks the worktree from the spec's `Base version`, so the stub — with
61
- that header — must exist **before** `spec-env up`:
62
-
63
- - From the base branch (`main`), create
64
- `specs/in-progress/hotfix-<name>/00-overview.md` with the header block
65
- (including `> **Type:** Hotfix` and `> **Base version:** <tag>`) and the
66
- `## Symptom` you established. It starts in `in-progress` — work begins now.
67
- - Run `skitterspec spec-env up hotfix-<name>`. It prints a `git worktree add …
68
- -b hotfix/<slug> <tag>` command (the branch forks from **the tag**, not
69
- `main`), the worktree path, and any `in the worktree, run:` bootstrap steps.
70
- - Run the printed `git worktree add`, then **move the stub across yourself.**
71
- This is where a hotfix differs from `/spec-bug`, which no longer needs the move:
72
- that skill's worktree forks from `main`, so committing the stub puts it there,
73
- while **this worktree is checked out at the tag** — a commit on `main` is not in
74
- it and never will be. The move is not redundant here; keep it.
75
- **Create the destination bucket first:**
76
-
77
- ```
78
- mkdir -p <worktreePath>/specs/in-progress
79
- mv specs/in-progress/hotfix-<name> <worktreePath>/specs/in-progress/
80
- ```
81
-
82
- The `mkdir -p` is not belt-and-braces. Git does not store empty directories, so
83
- `specs/in-progress/` is **absent** from the worktree whenever nothing was in
84
- progress at that point in history — and here that point is an
85
- **old release tag**, where it is absent more often than not. `mv` into a
86
- missing destination renames your spec folder **to** `specs/in-progress`,
87
- silently: the spec's files end up one level too high, `00-overview.md` sits
88
- where the bucket should be, and every later step still appears to work until
89
- something cannot find the spec. Confirm the result before carrying on — you
90
- want `<worktreePath>/specs/in-progress/hotfix-<name>/00-overview.md`. - **Bootstrap the worktree.** A fresh worktree has no installed dependencies and
58
+ ## 3. Provision the worktree from the tag, then write in it
59
+
60
+ **Provision before you write, not after.** §1 settled the tag and the name and
61
+ §2 put nothing on disk, so this is the one moment where a worktree costs nothing
62
+ to obtain and everything — the red test, the fix, the spec — is still ahead of
63
+ it:
64
+
65
+ ```
66
+ skitterspec spec-env up hotfix-<name> --docs --from <tag>
67
+ ```
68
+
69
+ **`--from` is how the tag reaches the engine, and it is not the header twice.**
70
+ Everywhere else `up` forks a hotfix from the spec's own
71
+ `> **Base version:**` line — but here the spec that would carry it does not
72
+ exist yet, which is the whole of what this lane is for. Without the flag the
73
+ branch would fork from `main`: the fix gets written against code prod is not
74
+ running, while the tag and the cherry-pick downstream go on believing it is on
75
+ the release line, and nothing says otherwise until someone asks why the patch
76
+ did not take. The engine refuses a ref git does not know rather than forking
77
+ from somewhere else quietly; where git cannot answer at all it passes the ref
78
+ through to `git worktree add`, which refuses far more loudly than a planner
79
+ guessing from an absence.
80
+
81
+ Run what it prints — the `git worktree add … -b hotfix/<slug> <tag>` is checked
82
+ out **at the tag**, not at `main` — then move this session into the worktree
83
+ with a plain `cd`; the Bash working directory persists between calls, so from
84
+ here on the test, the fix and the spec are all written on the hotfix's own
85
+ branch. Confirm the move landed rather than reading silence as success
86
+ (`.claude/rules/negative-checks.md` rule 1): `skitterspec spec-env resolve` with
87
+ no argument must name this spec. If it does not, say so and stop rather than
88
+ writing into a tree you cannot name.
89
+
90
+ **Then run `up` again, without the flags:**
91
+
92
+ ```
93
+ skitterspec spec-env up hotfix-<name>
94
+ ```
95
+
96
+ `--docs` skips the `setup` commands — everything whose only purpose is making a
97
+ tree *runnable* — because provisioning somewhere to write markdown should cost a
98
+ `git worktree add` and nothing else. A hotfix has no `/spec-start` to leave them
99
+ to: §4 runs the project's suite in this same tree, and
100
+ **a tree that cannot run the suite cannot go red**.
101
+ The flagless re-run re-attaches the worktree that now exists (no second `-b`
102
+ fork, and no fork point — the branch is already on the tag's line) and prints
103
+ the bootstrap steps the flag deferred.
104
+
105
+ **Why nothing is seeded on the base branch, and nothing is moved.** This step
106
+ used to write `specs/in-progress/hotfix-<name>/00-overview.md` in the primary
107
+ checkout for the engine to resolve, and then `mv` the folder across into a
108
+ worktree checked out at a tag — a move with a hazard paragraph of its own, since
109
+ `specs/in-progress/` is usually absent at an old release tag. Both halves are
110
+ gone. `main` is where work **lands**, not where it happens: where the project
111
+ installs `.claude/hooks/main-guard.cjs` that first write is refused outright,
112
+ and a step whose documented opening the repo will not perform is not a step. The
113
+ spec is written in the worktree at §5, on the branch it belongs to.
114
+
115
+ **§5 records the same tag you passed to `--from`**, on the spec's
116
+ `> **Base version:**` header. That is not bookkeeping: `/spec-complete` tags the
117
+ new patch and cherry-picks off the header, so a worktree forked from one ref and
118
+ a header naming another is this whole bug again with the halves swapped.
119
+
120
+ - **Bootstrap the worktree.** A fresh worktree has no installed dependencies and
91
121
  none of the repo's gitignored files (`.env`, local overrides). Run the printed
92
122
  `in the worktree, run:` steps (file seeding, then `setup`) in order, before
93
123
  anything else.
@@ -96,8 +126,9 @@ that header — must exist **before** `spec-env up`:
96
126
  — run `/add-dir <trusted root>` before editing into the worktree, or the first
97
127
  edits will prompt.
98
128
  - **Do everything below in the worktree**, on the `hotfix/<slug>` branch — the red
99
- test, the fix, and the rest of the spec. Act with absolute paths /
100
- `git -C <worktreePath>`, or open a fresh session rooted there. `main` changes
129
+ test, the fix, and the rest of the spec. The `cd` above is what makes that the
130
+ default rather than something to remember; where a step has to reach out of
131
+ the tree, use an absolute path or `git -C <worktreePath>`. `main` changes
101
132
  only at `/spec-complete` (via cherry-pick, not merge).
102
133
 
103
134
  ## 4. Write the failing test FIRST (RED) — mandatory
@@ -114,7 +145,8 @@ Encode the **correct** (expected) behaviour as a test, then run it and confirm i
114
145
 
115
146
  ## 5. Write the Hotfix spec
116
147
 
117
- Flesh out `00-overview.md` in the worktree (you seeded the stub in §3). A hotfix
148
+ Write `00-overview.md` in the worktree — §3 provisioned it at the tag and moved
149
+ you there, so create the folder and write it here. A hotfix
118
150
  is usually a single-pass fix, so the `## Fix` block can live directly in
119
151
  `00-overview.md`. Keep it lean:
120
152
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@skitterbyte/skitterspec",
3
- "version": "22.3.0",
3
+ "version": "22.3.2",
4
4
  "description": "Spec-driven development for Claude Code \u2014 a tracker-free filesystem workflow: lifecycle skills and per-spec isolation. For Linear sync, install @skitterbyte/skitterspec-linear instead.",
5
5
  "keywords": [
6
6
  "claude",
package/src/cli.js CHANGED
@@ -863,6 +863,9 @@ function specEnvUp(dir, config, specArg, flags = {}) {
863
863
  // THE AUTHORING LANE — `up <name> --docs` for a spec that is not written yet.
864
864
  // `/spec` provisions BEFORE it writes a line (feat-main-is-a-landing-zone
865
865
  // phase 2), so the name resolves to nothing at exactly the moment this runs.
866
+ // `/spec-bug` takes the same lane and then re-runs `up` WITHOUT the flag for
867
+ // the `setup` commands it skipped — it has no `/spec-start` to defer them to,
868
+ // and its next step runs the suite in that tree.
866
869
  // It opens only on a positive signal, all three parts: the not-found refusal
867
870
  // itself (by code, never by message), `--docs`, and a spec-shaped name — so a
868
871
  // typo'd `up` without the flag, or with a name no spec could have, refuses
@@ -883,6 +886,19 @@ function specEnvUp(dir, config, specArg, flags = {}) {
883
886
  // a caller that runs the printed commands must never own a worktree the
884
887
  // engine cannot name. `spec: true` is what makes the record resolve with
885
888
  // the spec's own type and slug rather than as `/no-spec` work.
889
+ // `--from <ref>` is the fork point for a spec whose own header cannot yet
890
+ // supply one. `/spec-hotfix` is the caller: its worktree belongs on the
891
+ // released line, and `> **Base version:**` is written INSIDE that tree.
892
+ // Everywhere else `spec.baseRef` comes off the spec and stays the
893
+ // authority — which is why passing this to a spec that resolves refuses
894
+ // below rather than quietly losing to the header.
895
+ if (flags.from !== undefined) {
896
+ const bad = badForkRef(dir, flags.from)
897
+ if (bad) {
898
+ process.stdout.write(bad)
899
+ return
900
+ }
901
+ }
886
902
  const beforeAuth = readRegistry(dir, config)
887
903
  const resolved = resolveSpecless(specArg, dir, config, { spec: true })
888
904
  writeRegistry(
@@ -891,9 +907,23 @@ function specEnvUp(dir, config, specArg, flags = {}) {
891
907
  recordSpecless(beforeAuth, specArg, { branch: resolved.branch, spec: true }),
892
908
  )
893
909
  spec = resolved
910
+ if (flags.from !== undefined) spec.baseRef = flags.from
894
911
  authoring = true
895
912
  }
896
913
 
914
+ // A flag that cannot be honoured is refused, never ignored. The operator
915
+ // typed a fork point; silently forking from base instead would build a hotfix
916
+ // against `main`'s code while everything downstream believes it is on the
917
+ // release line — the exact silent failure this flag exists to prevent.
918
+ if (flags.from !== undefined && !authoring) {
919
+ process.stdout.write(
920
+ `spec-env up: --from applies to a spec that is not written yet. ` +
921
+ `${specArg} already exists, and its own \`> **Base version:**\` header is ` +
922
+ 'the fork point — edit that instead.\n',
923
+ )
924
+ return
925
+ }
926
+
897
927
  // Checkout mode: the branch is built in the primary checkout, so none of the
898
928
  // worktree machinery below applies — no slot, no trust entry and no bootstrap.
899
929
  // Handled first precisely so none of that runs by accident.
@@ -933,13 +963,24 @@ function specEnvUp(dir, config, specArg, flags = {}) {
933
963
  const upGit = gitReader(dir)
934
964
  const upStatus = upGit(['status', '--porcelain'])
935
965
  const upDirtyPaths = dirtyPaths(upGit)
936
- // An authoring run does not ask whether the spec is on the fork point — it is
937
- // known to be nowhere, and that is the healthy state of the lane, not the
938
- // "would fork without the spec it is for" refusal. `onFork: undefined` is the
939
- // gate's cannot-tell branch, which allows (negative-checks rule 4). The
940
- // untracked read is skipped the same way: there is no folder to ask about.
941
- const upOnFork = authoring ? { onFork: undefined, foundOn: null } : specOnForkPoint(dir, upGit, spec)
942
- const upSpecUntracked = authoring ? false : specIsUntracked(dir, upGit, spec)
966
+ // A spec with no document on disk does not ask whether the spec is on the
967
+ // fork point — it is known to be nowhere, and that is the healthy state of
968
+ // the authoring lane, not the "would fork without the spec it is for"
969
+ // refusal. `onFork: undefined` is the gate's cannot-tell branch, which allows
970
+ // (negative-checks rule 4). The untracked read is skipped the same way: there
971
+ // is no folder to ask about.
972
+ //
973
+ // NOT `authoring`, which is only the run that OPENED the lane. The test-first
974
+ // skills re-run `up` without `--docs` for the setup commands before the spec
975
+ // is written, and that run resolves — off the registry record the lane left —
976
+ // so `authoring` is false while `spec.path` is still null. Asking git about a
977
+ // null path threw, which is a crash rather than a refusal: the condition is
978
+ // whether there is a document, and it always was.
979
+ const noDocument = authoring || !spec.path
980
+ const upOnFork = noDocument
981
+ ? { onFork: undefined, foundOn: null }
982
+ : specOnForkPoint(dir, upGit, spec)
983
+ const upSpecUntracked = noDocument ? false : specIsUntracked(dir, upGit, spec)
943
984
 
944
985
  // Trust the shared worktree root so edits into the freshly-provisioned worktree
945
986
  // don't prompt. One absolute entry (the root) covers every spec; self-heals on
@@ -1031,9 +1072,15 @@ function specEnvUp(dir, config, specArg, flags = {}) {
1031
1072
  // Positive, so an operator reading the plan knows the missing spec folder is
1032
1073
  // the lane working rather than something the engine failed to find.
1033
1074
  if (authoring) {
1034
- out.push(' authoring: no spec document yet — /spec writes it in the worktree')
1075
+ out.push(' authoring: no spec document yet — the skill writes it in the worktree')
1035
1076
  out.push(` recorded: added to ${config.registry} until the spec is written`)
1036
1077
  }
1078
+ // Only where one was NAMED. The fork point is otherwise base HEAD, which is
1079
+ // the unremarkable case, and a line asserting it on every provision would
1080
+ // make the one run that forked from a tag look like every other.
1081
+ if (authoring && flags.from !== undefined) {
1082
+ out.push(` fork: ${flags.from} (the branch forks from here, not from base)`)
1083
+ }
1037
1084
  if (trust.reason === 'malformed') {
1038
1085
  out.push(
1039
1086
  ' trusted: ! .claude/settings.local.json is not valid JSON — left it;' +
@@ -1084,6 +1131,38 @@ function speclessMap(dir, config) {
1084
1131
  }
1085
1132
 
1086
1133
  // A read-only git reader over `cwd`: returns trimmed stdout, or null on failure.
1134
+ /**
1135
+ * Is `ref` a fork point git will accept? Returns a refusal string, or null.
1136
+ *
1137
+ * THIS IS AN ACCUSATION, so it fires only on a positive signal
1138
+ * (`.claude/rules/negative-checks.md` rule 1). `rev-parse --verify` returning
1139
+ * nothing means one of two very different things — the ref does not exist, or
1140
+ * git could not answer at all — and only the first is evidence. So the control
1141
+ * question is asked first: if git cannot even name its own directory, the ref
1142
+ * is passed straight through and `git worktree add` refuses it far more loudly
1143
+ * than a planner guessing from an absence ever could (rule 4).
1144
+ *
1145
+ * WHAT WOULD FOOL THIS: a ref that exists but is not a commit — an annotated
1146
+ * tag object is peeled by `^{commit}`, so that case is covered, but a blob or
1147
+ * tree ref would be rejected here and rejected by `git worktree add` too. Being
1148
+ * wrong that way costs a refusal on something that could never have worked.
1149
+ */
1150
+ function badForkRef(cwd, ref) {
1151
+ const git = gitReader(cwd)
1152
+ if (!ref) {
1153
+ return 'spec-env up: --from needs a ref — the tag or commit to fork from.\n'
1154
+ }
1155
+ // The control question. No repo, no git, an unreadable directory — all land
1156
+ // here, and none of them is evidence about the ref.
1157
+ if (git(['rev-parse', '--git-dir']) === null) return null
1158
+ if (git(['rev-parse', '--verify', '--quiet', `${ref}^{commit}`]) !== null) return null
1159
+ return (
1160
+ `spec-env up: --from ${ref} — git does not know that ref here, so the ` +
1161
+ 'worktree would fork from somewhere else without saying so.\n' +
1162
+ ' check the tag exists (git tag --list), or fetch it first.\n'
1163
+ )
1164
+ }
1165
+
1087
1166
  function gitReader(cwd) {
1088
1167
  return (argv) => {
1089
1168
  try {
@@ -2576,18 +2655,58 @@ function specEnvReviewAllow(dir, config, tier, flags) {
2576
2655
  }
2577
2656
 
2578
2657
  // `review skip` — move on without a verdict, on the record.
2579
- function specEnvReviewSkip(dir, config, reason, flags) {
2658
+ /**
2659
+ * Split `skip`'s positionals into a spec and a reason.
2660
+ *
2661
+ * WHY IT IS NOT SIMPLY `positional[1]`. It was, and it made the gate's own
2662
+ * documented exit unreachable: a spec whose worktree is gone resolves neither
2663
+ * from the cwd nor from a sole provisioned spec, so the refusal told the
2664
+ * operator to "name the one you mean" — a thing the command could not accept —
2665
+ * while its worktree, the other half of that advice, no longer existed. The
2666
+ * gate was then permanent, and clearing it meant hand-editing a gitignored
2667
+ * sidecar. `review gate <spec>` and `review <spec> --claim-since` both take a
2668
+ * name; this was the one that did not.
2669
+ *
2670
+ * TWO POSITIONALS are unambiguous: spec, then reason.
2671
+ *
2672
+ * ONE IS DECIDED BY A POSITIVE SIGNAL (`.claude/rules/negative-checks.md`
2673
+ * rule 1) — it is a spec only where it RESOLVES to one. `findSpecFolder`
2674
+ * matches a bucket folder by exact basename, so nothing fuzzy gets in: a reason
2675
+ * has to be spelled exactly like a spec folder to be read as one, and a reason
2676
+ * that is spelled exactly like a spec folder is a person naming a spec. Every
2677
+ * cannot-tell — an unknown name, an unresolvable repo, a thrown resolver —
2678
+ * falls to the reason, which is the behaviour this command has always had and
2679
+ * the harmless branch.
2680
+ *
2681
+ * THE PROBE NEVER DECIDES THE OUTCOME, only which slot the word fills. What
2682
+ * happens next is the ordinary resolution, refusal text and all.
2683
+ */
2684
+ function splitSkipArgs(dir, config, rest) {
2685
+ const args = (rest || []).filter((a) => a !== undefined && a !== null)
2686
+ if (args.length >= 2) return { specArg: args[0], reason: args[1] }
2687
+ if (args.length === 1) {
2688
+ return gateTarget(dir, config, args[0]).spec
2689
+ ? { specArg: args[0], reason: null }
2690
+ : { specArg: null, reason: args[0] }
2691
+ }
2692
+ return { specArg: null, reason: null }
2693
+ }
2694
+
2695
+ function specEnvReviewSkip(dir, config, rest, flags) {
2696
+ const { specArg, reason } = splitSkipArgs(dir, config, rest)
2580
2697
  const said = String(reason || '').trim()
2581
2698
  if (!said) {
2582
2699
  // The reason IS the feature. A skip with no reason is the silence this
2583
- // whole gate exists to replace, so it is refused rather than defaulted.
2700
+ // whole gate exists to replace, so it is refused rather than defaulted —
2701
+ // and naming the spec is not giving one, which is why the probe above puts
2702
+ // a lone spec name in the spec slot and leaves this empty.
2584
2703
  process.stdout.write(
2585
- 'spec-env review skip: needs a reason — skitterspec spec-env review skip "<why>"\n',
2704
+ 'spec-env review skip: needs a reason — skitterspec spec-env review skip [<spec>] "<why>"\n',
2586
2705
  )
2587
2706
  process.exitCode = 1
2588
2707
  return
2589
2708
  }
2590
- const target = gateTarget(dir, config, null)
2709
+ const target = gateTarget(dir, config, specArg)
2591
2710
  if (!target.spec) {
2592
2711
  process.stdout.write(`spec-env review skip: cannot tell which spec — ${target.reason}\n`)
2593
2712
  process.exitCode = 1
@@ -4976,6 +5095,7 @@ async function specEnv(rest) {
4976
5095
  else if (args[i] === '--phase') flags.phase = args[++i]
4977
5096
  else if (args[i] === '--record-primary') flags.recordPrimary = true
4978
5097
  else if (args[i] === '--docs') flags.docs = true
5098
+ else if (args[i] === '--from') flags.from = args[++i]
4979
5099
  else if (args[i] === '--session') flags.session = args[++i]
4980
5100
  else if (args[i] === '--assert-primary-clean') flags.assertPrimaryClean = true
4981
5101
  else positional.push(args[i])
@@ -5114,10 +5234,10 @@ async function specEnv(rest) {
5114
5234
  break
5115
5235
  }
5116
5236
  if (positional[0] === 'skip') {
5117
- // The one positional is the REASON, not a spec: the two are
5118
- // indistinguishable as free text, and the spec is the one thing this
5119
- // engine can already resolve from where you are standing.
5120
- specEnvReviewSkip(dir, config, positional[1], flags)
5237
+ // `skip [<spec>] "<why>"`. The spec is usually resolvable from where you
5238
+ // are standing, which is why it stays optional — but not always, and
5239
+ // `splitSkipArgs` carries why that mattered enough to change.
5240
+ specEnvReviewSkip(dir, config, positional.slice(1), flags)
5121
5241
  break
5122
5242
  }
5123
5243
  await specEnvReview(dir, config, positional[0], flags, invokedFrom)
@@ -5134,7 +5254,7 @@ async function specEnv(rest) {
5134
5254
  ' review arm [spec] [--phase <n>] a phase ended — its diff now owes a verdict\n' +
5135
5255
  ' review gate [spec] [--check] [--json] is one owed? --check exits non-zero if so\n' +
5136
5256
  ' [--for-command <cmdline>] ...but only when that command is a git commit\n' +
5137
- ' review skip "<reason>" move on without one, on the record\n' +
5257
+ ' review skip [spec] "<reason>" move on without one, on the record\n' +
5138
5258
  ' review gate [spec] --offered record that the bypass was offered (spends it)\n' +
5139
5259
  ' review wait [spec] --heartbeat <secs> proof-of-life cadence on stderr; 0 disables\n' +
5140
5260
  ' review wait [spec] --since <iso> block until a verdict arrives ([--timeout <s>])\n' +