@skitterbyte/skitterspec 22.1.0 → 22.2.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.
@@ -201,6 +201,16 @@ escape hatch is what let a whole class of runs go on asking questions nobody was
201
201
  listening for. Where a watch is unavailable the wait is the turn ending, which
202
202
  every harness can do.
203
203
 
204
+ **Run it under a persistent watch primitive where the harness has one** — one
205
+ that lives for the whole session and whose deliberate stop is an explicit act
206
+ (Claude Code: the `Monitor` tool with `persistent: true`, ended by `TaskStop`).
207
+ A backgrounded shell is reclaimed on a long idle — that is the death the next
208
+ paragraph recovers from — where a session-length primitive is designed to
209
+ survive it. Where the harness has none, background the command exactly as
210
+ before. The engine's wait stays the one watcher either way: its heartbeat goes
211
+ to stderr, so stdout carries only the start line and the arrival, and the exit
212
+ ends the watch.
213
+
204
214
  **A watch that dies is re-armed, silently, on the same window.** A backgrounded
205
215
  wait does not always survive a long idle: three sessions lost theirs over one
206
216
  lunch break, and two in another were killed at 43 and 30 minutes. So a wait that
@@ -212,11 +222,17 @@ would strand exactly that pass.
212
222
  **Say nothing when you do it.** Nothing happened and the window is unchanged; a
213
223
  line on every death turns a working loop into a stream of non-events.
214
224
 
215
- **An operator who stopped it is not a death.** Re-arming over a deliberate stop
216
- makes the wait unstoppable, which is worse than a wait that stops. Where the two
217
- cannot be told apart, treat it as deliberate and leave it
218
- (`.claude/rules/negative-checks.md` rule 4): the cost of being wrong is one
219
- `/spec-reviewed`, against a loop nobody can break.
225
+ **An operator who stopped it is not a death** — re-arming over a deliberate
226
+ stop makes the wait unstoppable, which is worse than a wait that stops. But a
227
+ deliberate stop is an **explicit act with positive evidence**: this session
228
+ called TaskStop, or the operator said to stop waiting. A bare killed-task
229
+ notification carries neither and is read as a **death**. This clause once said
230
+ to treat the ambiguous kill as deliberate, and that safe branch ate the
231
+ recovery whole: every observed kill was the harness reclaiming an idle shell
232
+ and none was a person, so the case it protected against does not occur — while
233
+ the `/spec-reviewed` it charged was paid on every idle review. The deliberate
234
+ stop is the positive signal, so its absence is what a death looks like
235
+ (`.claude/rules/negative-checks.md` rule 1).
220
236
 
221
237
  **It is bounded by the age of the window, not by a count of retries.** Keep
222
238
  re-arming while the window is younger than **12 hours**, then stop. A count means
@@ -113,7 +113,9 @@ because writing it means reading the diff, and that spend is the user's call.
113
113
 
114
114
  1. **Note the moment you start waiting**, as an ISO timestamp. That instant is
115
115
  the whole scope of what you may claim.
116
- 2. **Run the engine's wait in the background, and end your turn:**
116
+ 2. **Run the engine's wait, and end your turn** — under the harness's
117
+ persistent watch primitive where one exists, else in the background
118
+ (`.claude/rules/spec-reports.md` carries the preference):
117
119
 
118
120
  ```
119
121
  skitterspec spec-env review wait <name> --since <the timestamp>
@@ -396,7 +396,9 @@ the verdict — and the reader's conclusion is about what the page showed them.
396
396
 
397
397
  1. **Note the moment you start waiting**, as an ISO timestamp. That instant is
398
398
  the whole scope of what you may claim.
399
- 2. **Run the engine's wait in the background, and end your turn:**
399
+ 2. **Run the engine's wait, and end your turn** — under the harness's
400
+ persistent watch primitive where one exists, else in the background
401
+ (`.claude/rules/spec-reports.md` carries the preference):
400
402
 
401
403
  ```
402
404
  skitterspec spec-env review wait <spec> --since <the timestamp>
@@ -232,8 +232,9 @@ skitterspec spec-env review arm <spec>
232
232
 
233
233
  **Then wait**, and `/spec-next` §5 owns the sequence — follow it there rather
234
234
  than reading a second copy here: note the moment, run
235
- `skitterspec spec-env review wait <spec> --since <that moment>` in the
236
- background, end your turn, and let `--claim-since` pick the one pass that
235
+ `skitterspec spec-env review wait <spec> --since <that moment>` under the
236
+ harness's persistent watch primitive where one exists (else in the
237
+ background), end your turn, and let `--claim-since` pick the one pass that
237
238
  arrived inside the window. **Do not compose a watcher**, and do not give the
238
239
  wait a timeout — the reasoning is in `/spec-next` §5 and is not repeated here.
239
240
  The banner it describes is what this skill emits in place of a `Review` row,
@@ -614,7 +614,9 @@ already arrived. Those get the `Review` row, no question, and nothing is owed.
614
614
 
615
615
  1. **Note the moment**, as an ISO timestamp, before you start. That instant is
616
616
  the entire scope of what may be claimed without a person naming it.
617
- 2. **Run the engine's wait in the background, and end the turn:**
617
+ 2. **Run the engine's wait, and end your turn** — under the harness's
618
+ persistent watch primitive where one exists, else in the background
619
+ (`.claude/rules/spec-reports.md` carries the preference):
618
620
 
619
621
  ```
620
622
  skitterspec spec-env review wait <spec> --since <timestamp>
@@ -436,7 +436,9 @@ reading — and that is the gap the whole gate exists to close.
436
436
 
437
437
  1. **Note the moment you start waiting**, as an ISO timestamp. That instant is
438
438
  the whole scope of what you may claim.
439
- 2. **Run the engine's wait in the background, and end your turn:**
439
+ 2. **Run the engine's wait, and end your turn** — under the harness's
440
+ persistent watch primitive where one exists, else in the background
441
+ (`.claude/rules/spec-reports.md` carries the preference):
440
442
 
441
443
  ```
442
444
  skitterspec spec-env review wait <spec> --since <the timestamp>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@skitterbyte/skitterspec",
3
- "version": "22.1.0",
3
+ "version": "22.2.0",
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
@@ -856,9 +856,42 @@ function specEnvNospec(dir, config, name, flags = {}) {
856
856
  }
857
857
 
858
858
  function specEnvUp(dir, config, specArg, flags = {}) {
859
- const spec = resolveSpecWithWorktree(dir, config, specArg)
860
859
  const docs = flags.docs === true
861
860
 
861
+ // THE AUTHORING LANE — `up <name> --docs` for a spec that is not written yet.
862
+ // `/spec` provisions BEFORE it writes a line (feat-main-is-a-landing-zone
863
+ // phase 2), so the name resolves to nothing at exactly the moment this runs.
864
+ // It opens only on a positive signal, all three parts: the not-found refusal
865
+ // itself (by code, never by message), `--docs`, and a spec-shaped name — so a
866
+ // typo'd `up` without the flag, or with a name no spec could have, refuses
867
+ // exactly as before. Worktree mode only: in checkout mode `/spec` writes
868
+ // where the session stands and there is nothing to provision first.
869
+ let spec
870
+ let authoring = false
871
+ try {
872
+ spec = resolveSpecWithWorktree(dir, config, specArg)
873
+ } catch (err) {
874
+ const specShaped = typeof specArg === 'string' && /^(feat|bug|hotfix)-./.test(specArg)
875
+ if (err.code !== 'SPEC_NOT_FOUND' || !docs || !specShaped || config.mode === 'checkout') {
876
+ throw err
877
+ }
878
+ // Record first, then plan — the same order `nospec` keeps, for the same
879
+ // reason: the record is what makes the name resolvable (a bare `resolve`
880
+ // in the fresh worktree must name this spec before its folder exists), so
881
+ // a caller that runs the printed commands must never own a worktree the
882
+ // engine cannot name. `spec: true` is what makes the record resolve with
883
+ // the spec's own type and slug rather than as `/no-spec` work.
884
+ const beforeAuth = readRegistry(dir, config)
885
+ const resolved = resolveSpecless(specArg, dir, config, { spec: true })
886
+ writeRegistry(
887
+ dir,
888
+ config,
889
+ recordSpecless(beforeAuth, specArg, { branch: resolved.branch, spec: true }),
890
+ )
891
+ spec = resolved
892
+ authoring = true
893
+ }
894
+
862
895
  // Checkout mode: the branch is built in the primary checkout, so none of the
863
896
  // worktree machinery below applies — no slot, no trust entry and no bootstrap.
864
897
  // Handled first precisely so none of that runs by accident.
@@ -898,8 +931,13 @@ function specEnvUp(dir, config, specArg, flags = {}) {
898
931
  const upGit = gitReader(dir)
899
932
  const upStatus = upGit(['status', '--porcelain'])
900
933
  const upDirtyPaths = dirtyPaths(upGit)
901
- const upOnFork = specOnForkPoint(dir, upGit, spec)
902
- const upSpecUntracked = specIsUntracked(dir, upGit, spec)
934
+ // An authoring run does not ask whether the spec is on the fork point — it is
935
+ // known to be nowhere, and that is the healthy state of the lane, not the
936
+ // "would fork without the spec it is for" refusal. `onFork: undefined` is the
937
+ // gate's cannot-tell branch, which allows (negative-checks rule 4). The
938
+ // untracked read is skipped the same way: there is no folder to ask about.
939
+ const upOnFork = authoring ? { onFork: undefined, foundOn: null } : specOnForkPoint(dir, upGit, spec)
940
+ const upSpecUntracked = authoring ? false : specIsUntracked(dir, upGit, spec)
903
941
 
904
942
  // Trust the shared worktree root so edits into the freshly-provisioned worktree
905
943
  // don't prompt. One absolute entry (the root) covers every spec; self-heals on
@@ -988,6 +1026,12 @@ function specEnvUp(dir, config, specArg, flags = {}) {
988
1026
  if (plan.docs) {
989
1027
  out.push(' docs: documents only — no setup commands, no docker')
990
1028
  }
1029
+ // Positive, so an operator reading the plan knows the missing spec folder is
1030
+ // the lane working rather than something the engine failed to find.
1031
+ if (authoring) {
1032
+ out.push(' authoring: no spec document yet — /spec writes it in the worktree')
1033
+ out.push(` recorded: added to ${config.registry} until the spec is written`)
1034
+ }
991
1035
  if (trust.reason === 'malformed') {
992
1036
  out.push(
993
1037
  ' trusted: ! .claude/settings.local.json is not valid JSON — left it;' +
@@ -488,17 +488,26 @@ function assertPrimaryOnMain(config, git) {
488
488
  */
489
489
  function resolveSpecless(name, dir, config, entry = {}) {
490
490
  const { repo, repoSlug } = repoInfo(dir)
491
- const slug = name
491
+ // AN AUTHORING RECORD RESOLVES AS THE SPEC IT WILL BE. `spec: true` marks a
492
+ // record written by `up --docs` for a spec not written yet (the `/spec`
493
+ // authoring lane), so type and slug come from the name's own prefix — the
494
+ // branch and worktree it answers with MUST equal what the written spec will
495
+ // later resolve to, or the confirm step lands in one tree and the documents
496
+ // in another. A plain `/no-spec` record keeps the whole name as its slug,
497
+ // byte-identical to before.
498
+ const authored = entry && entry.spec === true
499
+ const slug = authored ? splitPrefix(name).slug : name
492
500
  const tokens = { repo, repoSlug, slug }
493
501
  const spec = {
494
502
  folder: name,
495
503
  bucket: null,
496
504
  path: null,
497
- type: (entry && entry.type) || 'chore',
505
+ type: authored ? splitPrefix(name).type : (entry && entry.type) || 'chore',
498
506
  slug,
499
507
  stack: 'worktree',
500
508
  baseRef: null,
501
509
  specless: true,
510
+ unwritten: authored,
502
511
  }
503
512
  const branch = (entry && entry.branch) || branchFor(spec, config)
504
513
  const worktreeRoot = expandTokens(config.worktree.root, tokens)
@@ -532,10 +541,14 @@ function resolveSpec(specArg, dir, config, opts = {}) {
532
541
  }
533
542
  // Name the roots we looked under: the usual cause is a spec that only exists
534
543
  // on its own branch, and the message should say where we didn't find it.
535
- throw new Error(
544
+ // The code is what lets `up --docs` catch exactly this refusal and open the
545
+ // authoring lane — matching on the message would make its wording API.
546
+ const err = new Error(
536
547
  `spec not found under specs/**: ${specArg} ` +
537
548
  `(searched: ${[...preferDirs, dir, ...searchDirs].join(', ')})`,
538
549
  )
550
+ err.code = 'SPEC_NOT_FOUND'
551
+ throw err
539
552
  }
540
553
 
541
554
  const { type, slug } = splitPrefix(found.folder)
@@ -643,7 +656,14 @@ function allSpecs(dir, config, worktreePaths, specless = {}) {
643
656
  // not. Leaving them out is what would make a `/no-spec` worktree invisible to
644
657
  // `status` and to every bare resolution — the outcome decision 7 rejected for
645
658
  // a plain git branch, arrived at by omission instead.
659
+ //
660
+ // A FOLDER SHADOWS ITS OWN RECORD. An authoring record (`up --docs` on an
661
+ // unwritten spec) shares its name with the folder `/spec` then writes, and
662
+ // once that folder exists it is the answer — listing the record beside it
663
+ // would show one spec twice.
664
+ const collected = new Set(specs.map((s) => s.folder))
646
665
  for (const name of Object.keys(specless).sort()) {
666
+ if (collected.has(name)) continue
647
667
  const spec = resolveSpecless(name, dir, config, specless[name])
648
668
  specs.push({
649
669
  folder: spec.folder,