Repository navigation
v1.5.0
Fixed
- Three CI-only failures surfaced by PR #23, none reachable from a real install. (1)
scripts/validate.shCheck 7 useddeclare -Aandmapfile(both bash 4+), which crash on
macOS's stock/bin/bash3.2 (declare: -A: invalid option, thenmapfile: command not found
once the first was fixed) - rewritten as plain indexed arrays with linear-scan
q_get/q_set/fp_get/fp_appendlookup helpers and awhile readloop in place of
mapfile, no behavior change. (2) The
"Lesson validator (PowerShell)" CI step asserts the leaky fixture correctly FAILS validation,
but GitHub Actions appends an implicitexit $LASTEXITCODEto every pwsh step, so the
intentional non-zero exit code from the leaky-fixture check failed the step even though the
assertion itself passed - fixed with an explicitexit 0after the assertion. (3)
tests/hooks/run-conformance.ps1's-SelfTeststub bash script exits immediately without
reading stdin, and writing the JSON payload to its now-closed pipe raised an unhandled
IOException: Broken pipeon Linux runners -Invoke-HookProcessnow wraps the
StandardInput.Write/Closepair in a try/catch, since a child that never reads its input is
not a harness failure.
Added
-
/sd:status- a read-only reader for the metrics log (SW-16). SW-10 has been accumulating
.specs/_metrics/events.jsonlwith no consumer; the data existed and was invisible. The new
13th slash command summarises the live log plus.specs/index.md: specs in progress, gate
activity broken out by kind (verify/protected/code-edit) and decision
(allow/warn/block), lifecycle transitions, and a friction section ranking where the
operator is actually stuck - which specs are blocked most, which code-edit warns are being
ignored, which specs accumulate stale retros, and which in-progress specs are absent from the log
entirely. Read-only: no spec is created, no gate is evaluated, nothing is written.
Three decisions are worth recording because they diverge from a naive reading of the ticket.
(1)jqis an oracle, not a runtime dependency. The acceptance criterion "counts reconcile
againstjq" reads like a dependency; it is not. The schema is flat, metadata-only and written in
fixed key order, so exact substring counting is deterministic - andjqaborts on a
partially-written line, which would lose the whole report to one interrupted append, exactly what
the ticket forbids.jqverifies the numbers; it does not produce them.
(2) Counting is delegated to the shell, never to eyeballing. A capped log is ~8000 lines;
the command prescribes the exact count commands rather than asking for a summary, because a
number that was estimated cannot reconcile with an independent count.
(3) The live file only -events.jsonl.1is noted in one header line and never read, per the
read contract set in SW-15.
Every degrade path is a labelled state (ST001-ST005): no config, metrics disabled, log
absent, log empty. A blank report would read as "no friction", so an empty table is treated as a
defect rather than an edge case. Malformed lines are skipped and counted, and the skipped
count is always shown - a silent skip and a clean file are not the same fact.
Verification corpus attests/metrics/(populated / malformed / empty fixtures, expected numbers,
and thejqoracle procedure), pinned to LF in.gitattributes. It is documented as a manual
corpus:commands/status.mdis a prompt file and CI cannot execute it, so it is deliberately not
wired intoscripts/validate.*. -
Size cap and single-generation rotation for the metrics log (SW-15). A new
hooks.metrics.maxSizeKb
(default1024KB, ~1 MB) bounds.specs/_metrics/events.jsonl: before each append, if the live
file already meets or exceedsmaxSizeKb * 1024bytes, the hook rolls it toevents.jsonl.1
(single generation - any previous.1is overwritten) and starts fresh. Implemented in all four
metrics writers (spec-gateandsubagent-retro, PowerShell and bash) so the two platforms roll
at the same raw-byte boundary ((Get-Item).Length/wc -c). Inherits every SW-10 invariant:
rotation is best-effort and never stops the append (a silent stop would read as "metrics
working" while dropping data - worse than unbounded growth, per the ticket), a failed roll (locked
file on Windows, read-only dir) is a silent no-op, and it never alters a gate decision or the
hook's exit code. An absentmaxSizeKbis treated as1024, so aproject-config.json
written before this feature stays bounded with no edit; an explicit0/negative disables rotation,
and any non-number is invalid and also disables it (SW-22 type-strictness).events.jsonl.1is a
grace buffer, not part of any read contract - there is no consumer of the log today, and when
one exists it reads only the live file. Added totemplates/project-config.template.jsonand both
hooks' embedded default configs; documented indocs/architecture.mdanddocs/troubleshooting.md.
New conformance fixtures attests/hooks/fixtures/{spec-gate,subagent-retro}/metrics-rotates-at-cap
and.../metrics-rotation-failure-noopprove PS and bash rotate identically. -
Sanctioned mid-execution re-plan loop (SW-14). A new
sd-replan-loopskill defines a HARD Gate
Re-plan for the two workflows that produce a01-plan.md+02-tasks.mdpair -/sd:feature
and/sd:refactor- so a plan-invalidating discovery adapts the plan without violating
immutability or skipping a gate. The gate is reachable from both the Execute phase and the
batch/holistic review (the one real corpus failure surfaced at review, not mid-task). On
approval it appends anR<n>entry to an append-only## Revisionslog at the end of01-plan.md
(original plan prose left intact), regenerates only the affected task blocks in02-tasks.md
viasd-spec-architect(TASK = planwithREPLAN_SCOPE, no new architect mode), and marks each
regenerated taskRevised-by: R<n>(a conditional field insd-atomic-task-format, like refactor's
Parallel batch). Like Gate Complexity, it is a conditional gate that fires only on its trigger,
so/sd:featurestill advertises 3 hard gates and/sd:refactorstill 6. It never re-plans a
donespec. Scope was corrected from the ticket on evidence:/sd:bugand/sd:rcaproduce no
task list to re-plan, and/sd:perfalready carries its own revert-and-reselect loop, so all three
are left untouched. Seedocs/adr/0003-adaptive-replan-loop.md. -
SL070-SL073in/sd:spec validate: a new revision-log integrity band cross-checking the
## Revisionslog in01-plan.mdagainst theRevised-bymarkers in02-tasks.md.SL070
(dangling marker),SL071(one-sided/unreferenced revision), andSL072(broken append-only
history) are 🔴 BLOCK;SL073(malformed entry) is 🟠 WARN. The checks run only when a## Revisions
section or aRevised-bymarker exists, so a never-re-planned spec produces no finding.SL074-
SL079reserved. Honest boundary recorded in the ADR:validateis a static linter with no
Plan-phase snapshot, so it enforces the revision record's internal consistency but cannot detect an
unmarked silent edit by diffing - that is prevented by the gate, not the lint. -
Conformance fixtures at
tests/revision-log/fixtures/(SW-14): a valid revision record that passes
and a dangling-marker record that must BLOCK, pinned to LF via.gitattributes. They state the
contract; like the other fixture trees they have no runner (documented, not silently skipped). -
Complexity triage + forced decomposition in
/sd:feature(SW-13). The architect writes a
spec-levelcomplexityfrontmatter field (S|M|L, distinct from a task's
Estimated complexity) with a one-line rationale at create time. Gate 2 then measures the actual
plan against decompose thresholds - **> 8 tasks, > 2 production layers (Tests/Config excluded),8 impacted files, or an unresolved Open question** (the
> 8line set from the corpus canyon
between 3-4-task and 10-12-task specs; the Tests/Config exclusion keeps ordinary 2-layer mediums
under threshold).
Over threshold, Gate 2 becomes a HARD Gate Complexity that refuses one oversized plan and
forces a split into medium child specs (FEAT-<parent-arg>-<child-slug>, linked via existing
/sd:spec link spawns/depends-on; the parent becomes an immutablearchivedumbrella). Under
threshold it stays the normal plan approval with zero added friction - still 3 hard gates, not- A create-time
complexity: Lalso escalates models a tier (explorer ->sonnet, architect ->
opus, aliases only, per-invocation), deepening the impact map and plan for genuinely large work.
Task counts use the tolerantsd-atomic-task-formatheading grammar, not a naive### T<NN>
regex. Seedocs/adr/0002-complexity-triage-decomposition.md. Linting of the field + split
integrity is deferred to SW-4 (/sd:spec validate).
- A create-time
-
Field label grammar in
sd-atomic-task-format(SW-11). Task-block labels are now matched
case-insensitively, with**optional and the colon permitted inside or outside the emphasis -
all three forms found in live specs (- **Files**:,- Files:,- **Files:**) parse
identically. A field's value runs to the next field label, not the next newline, so multi-line
AcceptanceandPattern refsvalues are no longer truncated. The grammar is defined once and
applies to every field and every reader; per-field matchers are forbidden. -
SL060(WARN) in/sd:spec validate: a task block in02-tasks.mdwith noPattern refs
field.SL061-SL069reserved for further task-block content rules. This is the first rule
that reads inside a spec artifact rather than around it - see
docs/adr/0001-validate-parses-task-content.md. -
docs/adr/for specwright's own engine-level decision records, numbered the same way/sd:adr
numbers them (^[0-9]{4}-<slug>.md). Deliberately not.specs/_adr/:.specs/is Layer 2
(target-project context), and this repo has none. -
Conformance fixtures at
tests/task-format/fixtures/covering the three label forms plus a
negative case, pinned to LF via.gitattributes. They state the contract; they have no runner
(documented, not silently skipped). -
Lesson surfacing, part 3 and the close of the learning loop (SW-19, under epic SW-7):
subagent-retro.{ps1,sh}now emit a<retro-lessons>block when a subagent finishes work on an
in-progress spec, gated byhooks.subagentRetro.injectLessons(defaulttrue) and
maxLessons(default3). Placement is load-bearing: the emit sits beside the existing
metrics call site, before the staleness early-exit and before the debounce window - moved
down to the reminder block it would have surfaced lessons only to users already behind on their
retros, the population that needs them least. The one gate it keeps is the in-progress-spec
check, and that gate is the relevance filter: the workflow type of the in-progress spec selects
the scope (FEAT-pullsfeature,REF-pullsrefactor, andall-scoped lessons always
apply), so there is no ranking, no scoring, and no tie-break that could diverge between
implementations. This replaces theprompt-routerplacement and the
hooks.promptRouter.injectLessonskey named in the SW-7 epic; the epic records why.
Repetition is bounded per session rather than by a clock - a newshownLessonskey in the
hook state file records what has already been surfaced, somaxLessonscaps how many new
lessons appear at one stop and a session converges to silence once it has said everything
relevant. A time debounce was rejected because it would suppress a lesson the user has never
seen purely because a different one was shown recently. Four cross-implementation conformance
fixtures cover surfacing, scope filtering, already-shown state and the disabled flag, and the
conformance decision object now captures emitted lessons in emission order (sorting them would
hide exactly the selection-order divergence the fixtures exist to catch). -
Lesson aggregator, part 2 of the closed learning loop (SW-18, under epic SW-7):
scripts/aggregate-lessons.{ps1,sh}collect tagged lesson lines from every
<spec-dir>/*/05-retro.md, dedupe them, and render<spec-dir>/_lessons/lessons.md.
--check/-Checkwrites nothing and exits non-zero on drift, which is how idempotence is
asserted in CI. Two decisions differ from the SW-18 description and are recorded here: (1) the
retros are append-only andlessons.mdis a derived file regenerated on every run - the
ticket calledlessons.mditself append-only, but dedupe-with-a-count requires rewriting the
line, so append-only and idempotent are mutually exclusive; (2) abstraction stays in the
sd-retro-lessonsskill, so the aggregator makes no judgement calls and its output is
reproducible. Deduplication is on (tag, scope, case- and whitespace-normalised rule);
a repeat adds a count and never raises severity, and the surviving wording is resolved
independently of severity (byte-smallest) so a sloppier phrasing cannot win just by carrying a
lower one. All ordering is byte-wise -LC_ALL=Cin bash,[string]::CompareOrdinalplus an
ordinal dictionary comparer in PowerShell, whose culture-aware defaults would otherwise
diverge - and PowerShell writes UTF-8 without BOM and LF endings rather than going through
Set-Content. A committed corpus fixture and expected output pin both implementations to the
same bytes in CI; the corpus deliberately includes retros containing only/sd:spec status
transition lines (which must contribute zero lessons) and an out-of-enum tag (which must be
skipped). No hook is modified; surfacing (SW-19) follows. -
Structured retro lessons, part 1 of the closed learning loop (SW-17, under epic SW-7): new
sd-retro-lessonsskill defining a 10-tag enum, the one-line lesson record
(- [tag] severity/scope: Rule sentence.), and the abstraction discipline that turns a
retro note into a rule portable to another codebase. The tag enum is derived from a mined
corpus of real retros, not authored up front - two of the three tags originally proposed
in SW-7 were confirmed by that data and one (pattern-violation) was retired as overlapping
sibling-repo-assumptionandprecedent-conflict. New standalone validators
scripts/validate-lessons.ps1/.shenforce grammar, the closed tag/severity/scope sets, a
120-character ceiling, and the privacy contract (no paths, extensions, backticks, line
citations, or Pascal/camel/snake_case identifiers), so.specs/_lessons/lessons.mdis
shareable outside the org as-is. They are separate fromscripts/validate.*on purpose:
that validator checks this repo's own invariants, and specwright has no.specs/tree - these
take a file argument and default to.specs/_lessons/lessons.mdin the current directory, so
a consumer repo can run them directly. Paired fixtures undertests/lessons/fixtures/assert
both directions in CI (clean must pass, leaky must fail) - a validator that rots into a no-op
would otherwise report green forever. No hook is modified by this change; aggregation (SW-18)
and surfacing (SW-19) follow. -
Local, privacy-safe spec metrics (SW-10):
spec-gateandsubagent-retronow append one JSON
line per gate decision,index.mdlifecycle transition, and subagent-stop check to
.specs/_metrics/events.jsonl- metadata only (timestamp, spec ID, lifecycle phase, decision,
file extension), never a file path or code content. Controlled byhooks.metrics.enabledin
.claude/project-config.json, which defaults totrue- an existing install starts writing
.specs/_metrics/events.jsonlon the next hook run after upgrading, with no action required. Set
hooks.metrics.enabledtofalseto opt out entirely. No log rotation in v1 (documented as a
known limitation; ~120 bytes/line). Foundation for the closed retro-learning loop (SW-7). -
/sd:verify <spec-ID>traceability gate: SC-/AC-IDs in the feature template, aCovers
task field, a06-verify.mdpass artifact, and spec-gate hook enforcement (Rule 0 in
spec-gate.{ps1,sh}, flaghooks.specGate.verifyGate) that blocks a feature (FEAT-)
index.mdrow transitioning todonewithout a passing artifact. The gate is deliberately
scoped to feature specs - bug/refactor/perf/rca workflows produce no02-tasks.md, so
non-FEAT rows fall through to the unconditional protected-path block exactly as before,
pending a follow-up spec that integrates verify into those workflows./sd:spec status
pre-checks the artifact before mutating any file on a FEATin-progress -> donetransition
(prevents anSL030frontmatter/index strand), and/sd:featurePhase 6 requires an
evidence citation before ticking anAC-<n>checkbox. 11 new conformance fixtures pin the
gate, including the FEAT-only scoping (block-index-done-bug-row-protected) and the
documented bundled-edit limitation (allow-index-done-with-verify-bundled-edit). (SW-6) -
Cross-implementation hook conformance suite (
tests/hooks/): golden fixtures are piped into
both the bash and PowerShell implementation of every hook and the normalized decisions must
match; wired into CI on all matrix platforms with a self-test proving divergence detection (E4). -
Six more seeded lint rules in
examples/spec-lint-fixture/broken/(SW-4, seam 4), taking
coverage from 18 of 26 rules to 24:SL004(type/prefix mismatch),SL005+SL043(illegal
status, which cannot have a legal retro log and so always dragsSL043with it),SL021
(donewith a header-only retro),SL041(non-contiguous transition chain) andSL044
(archived -> in-progresswith an empty reason, the one WARN in the transition family). The
two remaining rules,SL001andSL013, need the fixture or the engine install itself to be
broken, so they need a corrupting harness rather than another seeded spec. -
Boundary documentation on the four seeds whose neighbouring rules overlap (SW-4, seam 4). The
transition rulesSL040-SL044are close enough that a linter can collapse several into one
and still look correct, so each seed is built to make exactly one fire and names in-file which
others must stay silent - e.g.PERF-BROKEN-012separatesSL021(retro exists but is empty)
fromSL043(no retro at all), andREF-BROKEN-013isolatesSL041behind two legal edges,
a matching last entry and a present retro. -
Severity-tagged output for
/sd:spec validate(SW-4, seam 3), with a stable rule table
(SL001-SL054). BLOCK is reserved for a registry that lies about itself or evidence that was
fabricated; WARN for a real but recoverable problem that leaves the registry truthful. The
command readssd-severity-taxonomyandsd-evidence-citationfrom disk at runtime, because
only agents load skills via frontmatter andvalidateinvokes no subagent. -
Anchor table in
sd-severity-taxonomy(SW-4, seam 3): BLOCK/WARN still requires an anchor, but
the legal anchor now depends on the target - a constitution§N.Mor acceptance criterion for
code, a lint rule ID for the.specs/tree. The code row stays strict. -
examples/spec-lint-fixture/(SW-4, seam 3): a clean.specs/tree that must report all-PASS
and a seeded-broken one covering 18 of the 26 lint rules, each violation self-documented with a
SEEDEDcomment. The two perf specs are a matched pair guarding the seam-1 regression: the
correct one (unfilled baseline atapproved) must PASS and the fabricated one must BLOCK.
Run by hand - the linter is a prompt, so CI cannot execute it; see the fixture README. -
linked_specsfrontmatter field on all five spec templates (SW-4, seam 2), replacing the
"Linked specs" body section that onlyfeature.template.mdever had -/sd:spec linkaccepted
any spec ID but had nowhere to write on the other four types. Cross-references are now a
structured YAML list maintained bylinkon both sides. -
Four structural checks in
/sd:spec validate(SW-4, seam 2): index <-> folder symmetry (orphan
folders, ghost rows, duplicate rows), transition replay against the state machine from the
05-retro.mdappend-only log (catches a hand-edited status that bypassed/sd:spec status),
link resolution (no dangling links), and link symmetry (no one-sided links). -
<<PHASE-N: ...>>token in the spec templates (SW-4, seam 1 of the/sd:spec validatelinter):
a distinguishable marker for cross-phase fields, replacing 20 phase-deferred fields that were
previously indistinguishable from author-fill<<placeholder>>s. This makes the engine's
cross-phase discipline machine-checkable in both directions -validatecan now assert that an
author-fill token is gone byapprovedand that a phase-deferred token is still there, so
pre-filling a field from memory is caught rather than merely discouraged. -
specwright.manifest.json(SW-3): canonical inventory contract declaring where assets live
(areas) and where the docs publish numbers about them (docClaims). Stores no counts - they
are derived from disk at runtime, so adding a command/agent/skill/template means adding the file
and nothing else. -
Check 7 (docs consistency) in
scripts/validate.{ps1,sh}: fails the build when a published
number disagrees with disk. Also fails on a vacuous claim (a pattern that matches nothing, i.e.
a reworded doc that silently disabled its own check) and on an undeclared claim (a number no
docClaimsentry covers). Closes the gap that let SW-1's drift reachmainwith CI green. -
scripts/selftest-docs.{ps1,sh}: negative self-test proving Check 7 still bites, by corrupting a
throwaway repo copy across four scenarios. Runs in CI on Ubuntu, macOS and Windows.
Changed
Pattern refsis required on every atomic task (SW-11), not only on tasks that create a new
file or public symbol. A task with no precedent writesPattern refs: noneexplicitly -none
asserts the architect looked, an absent field asserts nothing. Legacy blocks with the field
missing are still read asnone, so existing.specs/folders keep working; the omission is a
WARN, never a block. Task-block field count is now 11 across all docs (README,
docs/architecture.md,commands/feature.md,commands/refactor.md,
agents/spec-architect.md,agents/implementer.md), correcting a pre-existing drift where six
of those sites still said 9 after SW-6 bumped the skill to 10.- SW-11 explicitly did not add the
Context refsfield its ticket asked for.Pattern refs
already covers the need with 22-of-22 adoption in the live corpus; renaming would touch 37 sites
across 10 files for no measurable gain. Recorded in the ADR and on the ticket.
Fixed
- Four
subagent-retroconformance fixtures (lessons-already-shown,lessons-disabled,
lessons-scope-filter,lessons-surfaced) were non-deterministic: each expects a fresh retro
(emitted: false,stale: [],subagent_stopwithstale: 0) but shipped nosetup.json, so the
harness copied05-retro.mdwith its on-disk mtime and the case failed on any checkout older than
retroStaleMinutes(default 30 min) - the hook then read the retro as stale, flipped to
emitted: true, and the drifted lesson selection no longer matched the golden (SW-23). Each now
ships asetup.jsonthattouches its retro toageMinutes: 5, mirroring howremind-stale-retro
(120) andmetrics-emits-when-debouncedpin their fixtures; the four cases were introduced with the
lesson-injection loop (SW-19) and the omission stayed latent because the suite is usually run while
the retro is still fresh. Verified by backdating the retros two days on disk and confirming
65 passed / 0 failed (bash output byte-identical to pwsh, so this was always a fixture defect, never
a hook divergence). Test-only; no product-code or user-facing impact. - Four PowerShell hook config reads used PowerShell truthiness where the bash twin asks a
type-strict question, so the two implementations disagreed on the sameproject-config.json
(SW-22). Two failure modes, both invisible to a scaffolded project (the template ships
enabled: trueand non-zero numbers) but reachable by the hand-trimmed config a user writes to
change one setting. (1) Absentenableddisabled the hook in PowerShell only. AsubagentRetro
/specGateblock that omittedenabledleft the property$null, and-not $nullis$true,
sosubagent-retro.ps1andspec-gate.ps1exited silently;prompt-router.ps1had the same class
via[bool]$null(which is$false). All three bash twins use== false, so only a literal
falsedisables. The reads are now type-strict (-is [bool]/ return$falseonly for a real
booleanfalse), mirroring theverifyGateandmetrics.enabledreads already fixed this way.
(2) An explicit0was treated as absent.subagent-retro.ps1'sretroStaleMinutesand
debounceMinutesreads usedif ($config...), and PowerShell treats0as falsy, so an explicit
0was ignored and the default kept, while the bash// 30/// 10accept0; both now use
$null -ne, matching themaxLessonsread SW-19 fixed for the same reason. Bash was already
correct, so no.shchanged - the fix converges the pair. Five conformance fixtures added
(subagent-retro/{enabled-absent-still-on,stale-minutes-zero-honored,debounce-minutes-zero-honored},
spec-gate/enabled-absent-warns,prompt-router/enabled-absent-emits); every one fails if its
read is reverted - the previous fixture set could not, because all of them setenabledexplicitly
and used non-zero numbers. docs/architecture.mddescribed the metricsstalefield as a "count of stale/missing retros
observed for that spec". It is a per-event flag,0or1-subagent-retroemits one event per
in-progress spec per subagent stop and sets1when that spec's retro is stale or missing
(hooks/bash/subagent-retro.shemit_subagent_stop_metric). Retro pressure is measured by
counting1s over time, never by reading a single value as a quantity. Found while building the
first reader of the log (SW-16); the field had no consumer until now, so nothing had contradicted
the prose.- Check 7 could not see three whole classes of inventory claim, and each class had let a real,
wrong number sit in a tracked doc through many green runs (SW-24). TheclaimPhrasesvocabulary
inspecwright.manifest.jsonnow closes all three:
(1) Spelled-out numbers. Every pattern was anchored on[0-9]+, soREADME.md's intro line
saying "seven reusable skills" was invisible from the moment an eighth skill shipped in SW-17.
(2) Capitalisation. Adding a lowercase word alternation is not enough - a spelled-out count
in prose is usually sentence-initial, which is exactly where it is capitalised.Three hooks ship in cross-platform pairsindocs/architecture.mdescaped a lowercase-only fix. POSIX ERE (bash
[[ =~ ]]) has no inline case flag, so each word carries an explicit[Tt]-style class rather
than a flag only one of the two engines supports.
(3) Bare nouns. Only decorated forms were listed (slash commands,workflow commands), so
Five commands invoke no subagentmatched nothing at all - a line added by SW-16 itself, one
commit before this one. Barecommandsandagentsare now in the vocabulary.
Measured across the whole tracked tree: 4 real claims surfaced, 0 false positives.
The four offending lines are resolved under a policy now recorded in the manifest
($claimPolicyComment): if a number is derivable from an area, write it in digits and declare
it; if it is not derivable, publish no number and let the names carry the meaning. So
README.md's intro became digits with five newdocClaimsentries,Three hooks ship ...
became3 hooks ship ...with adocClaimsentry againsthooksPowerShell, and the two counts
that no area derives (Five commands invoke no subagent ...,the two hooks that record) had
the number removed - both already listed every item by name.
selftest-docs.{sh,ps1}grow from 4 scenarios to 6, one per new escape, and they are kept
separate on purpose: a fix that only adds a lowercase alternation passes scenario 4 and fails 5,
and a fix that only handles decorated nouns passes 5 and fails 6. Both were verified by
sabotage - reverting the vocabulary to digit-only makes scenario 5 reportTHE CHECK DID NOT BITEwhile 6 stays green, and removing the bare-noun entries produces the mirror image.
Check 7 now validates 52 published claims, up from 46. subagent-retro.ps1terminated its emitted block with[Console]::Out.WriteLine, which appends
[Environment]::NewLine- CRLF on Windows - so its output differed fromsubagent-retro.shby
exactly one byte on the final line. Both the<retro-reminder>and the new<retro-lessons>
block nowWritean explicitly LF-terminated string. Pre-existing; surfaced by SW-19's
byte-comparison requirement.selftest-docs.{sh,ps1}scenarios 2 and 3 had silently stopped testing anything (SW-20). Both
planted their corruption by string-replacing the literal**11 slash commands**; the repo now
ships 12, so the pattern matched nothing, the sandbox copy was never corrupted, the validator
correctly passed, and the scenario reportedTHE CHECK DID NOT BITE. Scenario 2's setup guard
could not catch this because it only checked that the planted text was present - and the
planted value (12) had since become the true value already inREADME.md, so the guard
found the real line and passed vacuously. Scenario 3 had no guard at all. Both counts are now
derived from disk (planttrue + 1, which can never collide), and both scenarios assert the
transition rather than the destination, reporting afixture setupfailure when the pattern
does not match. Check 7 itself was never broken - only the proof that it still bites, which had
been absent since the 12th command landed on an unpushed branch CI never ran. A hardcoded count
in the selftest was the last instance in the repo of the exact anti-pattern
specwright.manifest.jsonexists to abolish.subagent-retro's debounce state file, an on-disk contract shared between the two
implementations, was not written in the same shape by both (SW-5):subagent-retro.ps1wrote
the round-tripoformat with 7 fractional digits whilesubagent-retro.shwrote whole
seconds, so only the bash reader ever had to cope with fractions. PowerShell now writes the same
whole-secondyyyy-MM-ddTHH:mm:ssZstamp. The bash reader's two date fallbacks were also both
wrong on BSD/macOS: neither passed-u, so a UTC stamp was read as local time and skewed the
debounce window by the machine's offset, and the BSD branch handeddate -fa string with a
trailingZit would warn about on stderr - breaking the hook's silence. Both branches now
force UTC and the value is trimmed before parsing. The debounce branch had no fixture coverage
at all until now;setup.jsongrew awriteaction that plants a file whose content carries a
{{UTCNOW-45M}}-style token resolved at run time, so a state-file fixture cannot rot.spec-gatepath matching disagreed on case (SW-5).spec-gate.ps1compared with
OrdinalIgnoreCasethroughout;spec-gate.shused case-sensitive==andcaseglobs, so a
protected entry of.specs/Constitution.mdblocked an edit to.specs/CONSTITUTION.mdunder
PowerShell and allowed it under bash. bash now lowercases both sides for the protected list, the
allow-listed directory prefixes and the cwd-prefix strip. Case-insensitive is the right
semantics for a gate, not merely the parity-preserving one: Windows and macOS filesystems are
case-insensitive by default, so a case-sensitive rule is bypassable there by retyping the path.- The
spec-gatebasename allow-list let source files through under a documentation name (SW-5).
Both implementations allow-listed anything calledREADME*, soREADME.pybypassed the gate
outright, and the two disagreed on multi-dot names - bash'sREADME.*glob allowed
README.old.pywhile the PowerShell regex's single optional extension did not match it at all.
Only EXTENSION-LESSREADME/CHANGELOG/CONTRIBUTING/LICENSE/NOTICE/AUTHORSare now
allow-listed by name; everything with an extension is decided by the extension rules, so
README.mdis still a doc andREADME.old.pyis now correctly gated as Python. spec-gate.shapplied NO protected paths when.claude/project-config.jsonwas absent or
unparseable (SW-5), whilespec-gate.ps1applied its built-in defaults - so on a project that
had not run/sd:setupyet, the most common state there is, editing.specs/constitution.md
was blocked under PowerShell and silently allowed under bash. The bash fallback is now the same
full default document (.specs/constitution.md,.specs/index.md,LICENSEprotected;
mode: warn) instead of{}.Get-ProjectConfigin all three PowerShell hooks now reads the
config with-ErrorAction Stop, since the script-wideSilentlyContinuepreference could
otherwise turn a malformed config into a non-terminating error that skips thecatchand
returns$nullrather than the defaults.prompt-routerandsubagent-retrowere checked for
the same asymmetry and have none - every value they read has a matching//default - which is
now stated in both scripts so a future read does not quietly reintroduce it.- Conformance decision objects were too coarse to prove much (SW-5).
spec-gatedecisions kept
onlydecision/permissionDecisionand threw away the human-readablereason, which the two
implementations hand-duplicate - the reason strings could have drifted completely and all 20
cases would still have passed. The decision now carriesreason, and reports
REASON-MISMATCH-BETWEEN-SCHEMA-HALVESif the legacy andhookSpecificOutputcopies of it ever
disagree.subagent-retrodecisions likewise dropped the measured age and the threshold it was
compared against, so the two implementations could have disagreed on the arithmetic unnoticed;
both are now asserted, andsubagent-retro.shrounds the age to the nearest minute instead of
truncating it, matchingsubagent-retro.ps1's[Math]::Round. Every decision object now also
carriesstderr, so the repo's "every failure path exits 0 SILENTLY" invariant is actually
checked rather than assumed - a hook that regressed into printing a diagnostic on every
invocation used to pass. - Two bash hook bugs surfaced by the cross-implementation conformance suite (SW-5).
prompt-router,
spec-gateandsubagent-retroall readenabledwith jq's//operator, which treats an
explicit JSONfalseas absent - a project that setenabled: falseinproject-config.json
got a hook that ran anyway; the three scripts now use anif/then/elsejq expression that
compares directly againstfalse. Separately,spec-gate's protected-path loop never blocked a
protected path on Windows because Windowsjq.exeemits CRLF forjoin("\n")output, leaving a
trailing\ron each path that broke the exact-match comparison; the loop now strips a trailing
CR before comparing, mirroring the existing strip inprompt-router.sh's keyword loop. - Three spec stubs in
examples/spec-lint-fixture/broken/(SW-4, seam 4) raised an unlisted
SL011BLOCK:BUG-BROKEN-001andBUG-BROKEN-008carried none of the bug template's four
phase-3 tokens andRCA-BROKEN-005carried four of the rca template's seven, because each had
dropped the enclosing section wholesale. Atdrafta spec must carry at least its template's
per-phase token count, so all three failed a rule the fixture's expected-findings table does
not list - which would have read as a linter bug rather than a fixture one. Found by running
the linter against the tree rather than by inspection, which is the first time the SW-4
acceptance criterion was executed end-to-end rather than reasoned about. - Placeholder tokens spelled out inside
<!-- SEEDED: ... -->comments in the fixture (SW-4,
seam 4). A token named in a comment is indistinguishable from a real one to any linter that
scans line-wise rather than parsing, so the comments explaining the placeholder rules were
themselves seeding phantom findings in a tree whose contract is "these findings and no others".
The comments now describe tokens in prose. /sd:spec linkinverse map (SW-4) was partial and ambiguous: it accepted 9 relations but
defined inverses for only 5, soblocks,blocked-by,spawned-byandsuperseded-byhad no
defined other side.depends-onandblocked-byalso asserted the same edge in two spellings.
blocked-byis now an input alias normalized todepends-on, and the map is total and closed -
every stored relation has exactly one inverse, which is what makes link symmetry checkable./sd:spec validaterequired-field rules (SW-4) had drifted from
skills/sd-spec-templates/SKILL.md, the skill that authors the specs:validatechecked only
id/type/status/created(+severityfor bug, +incident_startedfor rca), so it passed
malformed specs missingtarget_metric(perf),smell(refactor),jira(feature/bug) and
incident_resolved(rca). The rules are now per-type and match the skill./sd:spec validateplaceholder rule (SW-4) contradicted the templates it validates: "status >=
approved-> no<<placeholder>>remaining" failed a correct perf spec, whose baseline field
must still be unfilled atapprovedby the cross-phase rule inCLAUDE.md. Author-fill and
phase-deferred tokens are now separate forms with separate rules.- Doc count/inventory drift (SW-1):
README.mdlisted/sd:setupat no gates (-->2, matching
the two approval gates incommands/setup.md) and omittedsd-docs-writerfrom
sd-evidence-citation's "Used by" list (4 agents, not 3). - Stale
MSSQLreferences in the docs, left over from the stack-agnostic database rename
(mcp.mssql->mcp.database):docs/architecture.mdlisted a hardcoded MSSQL tool in
sd-debugger's tool surface and anmssqlserver in the project-scope MCP table;README.md
named MSSQL in the MCP-friendly summary and the MCP table;docs/troubleshooting.mdhad an
MSSQL-titled section. All now describe the project-provided database MCP, matching
agents/debugger.mdandtemplates/project-config.template.json. AddressesREVIEW-TODO.md
item 5's doc half; theagents/debugger.mdbody-vs-allowlist defect it also names remains open. spec-gate's protected-path matching could be bypassed via..path traversal under the bash
hook:spec-gate.ps1normalizesfile_pathwith[System.IO.Path]::GetFullPath, which resolves
../.segments before comparing againstpaths.protected, butspec-gate.sh'snormalize_rel
only normalized separators and stripped the cwd prefix - it never collapsed... A path like
<cwd>/src/../.specs/constitution.mdreached the protected constitution file while presenting a
relative form (src/../.specs/constitution.md) that matched nothing inpaths.protected, so
bash exited 0 and silently allowed editing a protected file that PowerShell correctly blocked.
spec-gate.shnow collapses./..segments with pure string processing (norealpath,
readlink -f, orcd, since the file may not exist yet underWriteand the decision must not
depend on filesystem state) before the protected-path and allow-list comparisons, clamping a
rooted..at its own root the same wayGetFullPathdoes, and falling back to the raw,
un-collapsed path when resolution would escape the workspace entirely - matching
ConvertTo-RelativePath's own fallback branch. Covered by three new conformance fixtures:
..traversing into a protected file, a bare.segment, and a benign..that resolves to a
non-protected code file, proving the fix does not over-block.- The bash-side
..traversal fix above was one-sided:spec-gate.ps1had the mirror-image
weakness, still live, letting the same class of edit through under PowerShell. Its
ConvertTo-RelativePathcalled[System.IO.Path]::GetFullPath($FilePath)on a RELATIVE
file_path, which resolves it against this hook PROCESS's own working directory rather than the
cwdsupplied in the hook payload; the result then failed the base-prefix check and fell through
to the raw, un-collapsed path, matching nothing inpaths.protected. A relative
src/../.specs/constitution.mdtherefore reached the protected constitution file while
PowerShell exited 0 silently and bash (already fixed) correctly blocked it. Separately,
GetFullPathpreserves a trailing path separator, so<cwd>/.specs/constitution.md/failed the
protected-path equality test outright and, sinceGetExtensionalso returns""for a
trailing-separator path, was not even caught by the code-file rule - a second silent bypass.
spec-gate.ps1now collapses./..segments with the same pure string processing as
spec-gate.sh'scollapse_dot_segments/normalize_rel(a relativefile_pathis collapsed
directly rather than joined onto the process cwd; a trailing separator collapses away as a
no-op segment) so the two implementations resolve identically.prompt-routerand
subagent-retrowere checked for the same pattern and do not have it - neither reads
tool_input.file_pathor compares a user-supplied path againstpaths.protected. Covered by
three new conformance fixtures: a relative..traversal into the protected constitution file,
a trailing separator on the protected constitution file, and a benign relative..resolving to
a non-protected code file, proving the fix does not over-block. - Extended
.gitattributeswith a repo-wide* text=autodefault plus*.shand*.ps1pinned to
eol=lf, so shell scripts no longer check out as CRLF on Windows, where a#!/usr/bin/env bash
line with a trailing CR fails withbad interpreterand heredocs /[[ ... ]]mis-parse (SW-21).
Folds the previously narrow, fixtures-only policy into a repo-wide one; the byte-comparison fixture
pins (tests/**) stay because* text=autostill yields a native CRLF checkout on Windows. The
first checkout after this lands renormalizes line endings in existing Windows working trees - a
one-time large diff, not a real change.