space-architect 5.5.1 → 7.0.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.
@@ -46,7 +46,7 @@ module Space
46
46
  end
47
47
 
48
48
  def source_skills
49
- source_root.children.select(&:directory?)
49
+ Space::Core::Paths.layout_children(source_root).select(&:directory?)
50
50
  end
51
51
 
52
52
  private
@@ -93,8 +93,8 @@ module Space
93
93
  def same_content?(source, dest)
94
94
  return false unless dest.directory?
95
95
 
96
- source_files = Dir.glob("#{source}/**/*").reject { |f| File.directory?(f) }
97
- dest_files = Dir.glob("#{dest}/**/*").reject { |f| File.directory?(f) }
96
+ source_files = Space::Core::Paths.content_tree(source).reject { |f| File.directory?(f) }
97
+ dest_files = Space::Core::Paths.content_tree(dest).reject { |f| File.directory?(f) }
98
98
 
99
99
  return false if source_files.length != dest_files.length
100
100
 
@@ -31,7 +31,9 @@ Write + commit: `architect section <%= @_name %> specification --from <file>`. -
31
31
  machine-readable declaration lives in the fenced ```lanes block below — the single
32
32
  frozen source of truth `architect freeze` records and `architect provision`
33
33
  materializes.
34
- - **Effort** — `think hard` … `ultrathink` per lane, with one line of why.
34
+ - **Effort** — per lane, set at dispatch: `architect dispatch --effort <level>`,
35
+ translated + clamped to the lane's harness, with one line of why (the
36
+ escalation keywords `think hard` … `ultrathink` still work in-prompt).
35
37
 
36
38
  ```lanes
37
39
  # One entry per lane (1–4). The frozen out-of-bounds contract: `architect freeze`
@@ -39,17 +41,33 @@ Write + commit: `architect section <%= @_name %> specification --from <file>`. -
39
41
  # materializes the worktrees + lane branches. Remove the comment markers to activate.
40
42
  # - name: lane-a # lane name (required)
41
43
  # repo: my-repo # target repo under repos/ (required)
42
- # touch: # file globs this lane may write (required, non-empty)
43
- # - lib/my_repo/**
44
+ # touch: # every file this lane may write, enumerated — no globs (required, non-empty)
45
+ # - lib/my_repo/foo.rb
46
+ # - lib/my_repo/bar.rb
44
47
  # - test/my_repo_test.rb
45
48
  ```
46
49
 
47
50
  ## Acceptance Criteria
48
51
 
49
52
  <!-- PROOF. Write the prose conditions of correctness (AC1, AC2, …) that the
50
- architect judges against. Runnable checks live in the fenced ```gates block below
53
+ architect judges against. Calibrate precision to the property (SKILL.md §4): a
54
+ property criterion is bounded from the defect's side only — freeze a floor, not
55
+ an equality; exactness is for criteria whose number is itself the deliverable.
56
+ A presence-grep gate on prose is a tripwire, never the proof — its criterion
57
+ should say which. Runnable checks live in the fenced ```gates block below
51
58
  (parsed at freeze time — absent or empty is allowed; malformed fails freeze).
52
- `architect freeze <%= @_name %>` commits this file and records its SHA as
59
+ Pre-freeze check snapshot: no count/name-set/byte-identity frozen at merely
60
+ today's value; control: every baseline actually run this session; mechanism: no
61
+ *how* where the *what* is the requirement; interface: every CLI surface the
62
+ Specification names (verb, flag spelling, subcommand) executed once before it
63
+ freezes; dry-run:
64
+ `architect rehearse <%= @_name %>` — RED (clean non-zero) discriminates; GREEN
65
+ is a declared regression guard or a gate that measures nothing; BROKEN is
66
+ advisory (a correct RED can look broken — confirm it); EMPTY is no gates or an
67
+ untouched placeholder.
68
+ `architect freeze <%= @_name %>` needs a fresh rehearsal stamp — editing gates
69
+ stales it; `--skip-rehearse REASON` records the skip. The stamp records that you
70
+ looked, never that gates passed. Freeze commits this file and records its SHA as
53
71
  freeze_sha. Read-only afterward — any change to Grounds/Specification/Acceptance
54
72
  Criteria = automatic iteration FAIL. -->
55
73
 
@@ -60,12 +78,22 @@ Criteria = automatic iteration FAIL. -->
60
78
 
61
79
  ```gates
62
80
  # Each gate backs one prose AC above. Remove the comment markers to activate.
81
+ # `cmd` paths resolve against the REPO TREE; `cwd` is relative to the SPACE
82
+ # ROOT. The judge-time remap is asymmetric: a `cwd` under repos/<repo> is
83
+ # remapped into the lane worktree, one outside it passes through unchanged.
84
+ # End a multi-step `cmd` with `echo SENTINEL` + `stdout_match`: /bin/sh (the
85
+ # gate runner's shell) has no `set -e`, so an early branch can exit 0 — the
86
+ # sentinel proves the command reached its end.
87
+ # Draft, then run `architect rehearse <%= @_name %>` — before the freeze, while
88
+ # a bad gate still costs nothing.
63
89
  # - id: suite-green # unique slug within the iteration (required)
64
90
  # ac: AC1 # which prose AC this gate backs (required)
65
- # cwd: repos/my-repo # run dir, relative to space root (optional; consumed by I03)
66
- # cmd: bundle exec rake test # shell command (required, non-empty)
91
+ # cwd: repos/my-repo # run dir, space-root-relative (optional)
92
+ # cmd: |- # block style by default a plain scalar breaks on `: `
93
+ # bundle exec rake test && echo SUITE_OK
67
94
  # expect: # at least one of: exit_code, stdout_match, threshold
68
95
  # exit_code: 0
96
+ # stdout_match: SUITE_OK
69
97
  ```
70
98
 
71
99
  ## Builder Prompt
@@ -90,6 +90,7 @@ module Space::Core::CLI
90
90
  # members within a group, ordered by the declared order), with undeclared
91
91
  # children (namespaces) trailing in the default group.
92
92
  def grouped_listing(result)
93
+ # paths:exempt - result.children is a dry-cli command-tree node, not a filesystem path
93
94
  decorated = result.children.filter_map do |name, node|
94
95
  [name, node, phase_of(node)] unless node.hidden
95
96
  end
@@ -150,6 +151,7 @@ module Space::Core::CLI
150
151
  end
151
152
 
152
153
  def banner(node)
154
+ # paths:exempt - node.children? is a dry-cli command-tree predicate, not a filesystem path
153
155
  if node.command && node.leaf? && node.children?
154
156
  " [ARGUMENT|SUBCOMMAND]"
155
157
  elsif node.leaf?
@@ -8,7 +8,7 @@ module Space::Core
8
8
  # "--flag" boundaries, continuation lines indented two spaces. Commands
9
9
  # without "--" flags are returned unchanged.
10
10
  def wrap(command)
11
- parts = command.split(/(?= --)/)
11
+ parts = split_at_flag_boundaries(command)
12
12
  return command if parts.size <= 1
13
13
 
14
14
  parts.each_with_index.map do |part, i|
@@ -16,5 +16,36 @@ module Space::Core
16
16
  i < parts.size - 1 ? "#{segment} \\" : segment
17
17
  end.join("\n")
18
18
  end
19
+
20
+ # Same split points as command.split(/(?= --)/), but blind to " --" that
21
+ # falls inside a single- or double-quoted flag value, so a --title
22
+ # containing " -- " isn't torn apart mid-argument.
23
+ def split_at_flag_boundaries(command)
24
+ parts = []
25
+ start = 0
26
+ in_squote = false
27
+ in_dquote = false
28
+ escaped = false
29
+ command.each_char.with_index do |ch, i|
30
+ if escaped
31
+ escaped = false
32
+ next
33
+ end
34
+ case ch
35
+ # A backslash escapes the next character outside quotes and inside
36
+ # double quotes, but is a literal character inside single quotes.
37
+ when "\\" then escaped = true unless in_squote
38
+ when "'" then in_squote = !in_squote unless in_dquote
39
+ when '"' then in_dquote = !in_dquote unless in_squote
40
+ when " "
41
+ if !in_squote && !in_dquote && command[i + 1, 2] == "--"
42
+ parts << command[start...i]
43
+ start = i
44
+ end
45
+ end
46
+ end
47
+ parts << command[start..]
48
+ parts
49
+ end
19
50
  end
20
51
  end
@@ -1,9 +1,44 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "pathname"
4
+
3
5
  module Space::Core
4
6
  module Paths
7
+ # paths:exempt-file - the shared module itself — this is the one home the guard defends
5
8
  module_function
6
9
 
10
+ # Flags for matching a changed path against a lane's touch_set globs.
11
+ # PATHNAME keeps a single `*` from crossing `/`; EXTGLOB enables `{a,b}`;
12
+ # DOTMATCH lets a glob reach dotfile segments, so a `dir/**` touch set covers
13
+ # `dir/.github/workflows/ci.yml` — the standard deliverable for a lane preparing
14
+ # a directory to become a repo root.
15
+ TOUCH_FNM = File::FNM_PATHNAME | File::FNM_EXTGLOB | File::FNM_DOTMATCH
16
+
17
+ # Every path beneath root, at every depth: files, directories, dotfiles, and
18
+ # dot-directory contents all included. `File::FNM_DOTMATCH` is what makes
19
+ # dotfiles visible to `Dir.glob`, but it also emits a bogus `root/.`
20
+ # self-entry — that trap is absorbed here so no callsite has to know about it.
21
+ # Returns Array<String> — Dir.glob's native return shape.
22
+ def content_tree(root)
23
+ Dir.glob(File.join(root.to_s, "**", "*"), File::FNM_DOTMATCH).reject { |p| File.basename(p) == "." }
24
+ end
25
+
26
+ # The direct children of a fixed-depth structured directory layout (one
27
+ # entry per iteration/skill/lane/space) where a leading dot never names a
28
+ # real layout member — only tooling junk (.git, .DS_Store). Excludes them.
29
+ # Returns Array<Pathname> — Pathname#children's native return shape.
30
+ def layout_children(dir)
31
+ Pathname.new(dir).children.reject { |c| c.basename.to_s.start_with?(".") }
32
+ end
33
+
34
+ # Does a single touch_set glob match path? A trailing `dir/**` is matched
35
+ # twice: bare (PATHNAME stops it at direct children) and as `dir/**/*`,
36
+ # whose whole-component `**/` does cross `/`.
37
+ def touch_match?(glob, path)
38
+ File.fnmatch(glob, path, TOUCH_FNM) ||
39
+ (glob.end_with?("/**") && File.fnmatch("#{glob}/*", path, TOUCH_FNM))
40
+ end
41
+
7
42
  def contract(path, env: ENV)
8
43
  value = path.to_s
9
44
  home = XDG.home(env: env)
@@ -53,7 +53,7 @@ module Space::Core
53
53
  def list
54
54
  return [] unless spaces_dir.directory?
55
55
 
56
- spaces_dir.children.select(&:directory?).filter_map do |child|
56
+ Paths.layout_children(spaces_dir).select(&:directory?).filter_map do |child|
57
57
  Space.load(child)
58
58
  rescue NotFoundError, Error
59
59
  nil
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Space
4
4
  module Core
5
- VERSION = "5.5.1"
5
+ VERSION = "7.0.0"
6
6
  end
7
7
  end
@@ -43,6 +43,19 @@ module Space::Src
43
43
  # the next spawn. No-op when the log is missing or
44
44
  # under-threshold (sync tests in G4 stay green).
45
45
  rotate_plist_logs(paths)
46
+
47
+ # Resolved BEFORE the scoping block: the "scoping sync to:"
48
+ # notice below is prose, and must be suppressed in JSON mode
49
+ # for the same reason the summary line is. Resolution depends
50
+ # only on flags/env/out, so it is safe this early.
51
+ mode = UI::Mode.resolve(
52
+ flags: {plain: plain, json: json, no_color: no_color, quiet: quiet},
53
+ env: CLI.env,
54
+ out: out
55
+ )
56
+ # Every line of --json output must parse as one JSON object.
57
+ human = mode.format != :json
58
+
46
59
  if repo
47
60
  target = scope_target(repo)
48
61
  return fail_with(self, "invalid repo reference: #{repo.inspect} (expected host/owner/name)") if target.failure?
@@ -57,14 +70,9 @@ module Space::Src
57
70
  # exactly the G4 "other repo gets no state row" test
58
71
  # path, so we explicitly empty orgs here).
59
72
  config = Config::Store.with(config, repos: [found], orgs: [])
60
- out.puts "scoping sync to: #{Repo::Helpers.format_ref(found)}"
73
+ out.puts "scoping sync to: #{Repo::Helpers.format_ref(found)}" if human
61
74
  end
62
75
 
63
- mode = UI::Mode.resolve(
64
- flags: {plain: plain, json: json, no_color: no_color, quiet: quiet},
65
- env: CLI.env,
66
- out: out
67
- )
68
76
  reporter = if mode.format == :json
69
77
  UI::JsonReporter.new(out)
70
78
  elsif mode.animate
@@ -73,14 +81,19 @@ module Space::Src
73
81
  UI::PlainReporter.new(out, mode: mode)
74
82
  end
75
83
 
76
- result = Space::Src::Sync::Engine.new(reporter: reporter).call(config: config, paths: paths)
84
+ # prune only on a full sweep. With --repo the config above was
85
+ # narrowed to one repo, so pruning would delete the state rows
86
+ # for every OTHER tracked repo.
87
+ result = Space::Src::Sync::Engine.new(reporter: reporter)
88
+ .call(config: config, paths: paths, prune: repo.nil?)
77
89
  if result.failure?
78
90
  return fail_with(self, "sync failed: #{format_failure(result.failure)}")
79
91
  end
80
92
 
81
- new_state = result.success
82
- n = new_state.repos.size
83
- out.puts "synced #{n} repo(s)"
93
+ report = result.success
94
+ # JsonReporter already emits a run_finished event carrying the
95
+ # summary, so suppressing the prose line loses nothing.
96
+ out.puts "synced #{report.processed} repo(s)" if human
84
97
  CLI.record_outcome(Outcome.new(exit_code: 0))
85
98
  end
86
99
 
data/lib/space_src/cli.rb CHANGED
@@ -69,6 +69,7 @@ module Space::Src
69
69
  def self.bare_query?(argv)
70
70
  return false if TOP_LEVEL_HELP.include?(argv)
71
71
  return false if VERSION_REQUEST.include?(argv)
72
+ # paths:exempt - Registry.get([]).children is a dry-cli command-tree node, not a filesystem path
72
73
  argv.length == 1 && !Registry.get([]).children.key?(argv[0])
73
74
  end
74
75
 
@@ -37,6 +37,7 @@ module Space::Src
37
37
  parts = name.split("/")
38
38
  case parts.length
39
39
  when 1
40
+ # paths:exempt - layout intent (checkout resolution by depth); space_src is deliberately independent of space_core and must not require it
40
41
  candidates = Dir.glob(File.join(@base_dir, "*", "*", name))
41
42
  case candidates.length
42
43
  when 0 then Failure("#{name.inspect} not found under base_dir #{@base_dir}")
@@ -45,6 +46,7 @@ module Space::Src
45
46
  end
46
47
  when 2
47
48
  owner, repo_name = parts
49
+ # paths:exempt - layout intent (checkout resolution by depth); space_src is deliberately independent of space_core and must not require it
48
50
  candidates = Dir.glob(File.join(@base_dir, "*", owner, repo_name))
49
51
  case candidates.length
50
52
  when 0 then Failure("#{name.inspect} not found under base_dir #{@base_dir}")
data/lib/space_src/nav.rb CHANGED
@@ -16,6 +16,7 @@ module Space::Src
16
16
  def self.scan(base_dir)
17
17
  pattern = File.join(base_dir, "*", "*", "*")
18
18
  prefix = base_dir.chomp("/") + "/"
19
+ # paths:exempt - layout intent (depth-3 checkout scan); space_src is deliberately independent of space_core and must not require it
19
20
  Dir.glob(pattern).filter_map do |path|
20
21
  next unless File.directory?(path)
21
22
  relative = path.delete_prefix(prefix)
@@ -13,6 +13,7 @@ require "space_src/state/store"
13
13
  require "space_src/state/lock"
14
14
  require "space_src/paths"
15
15
  require "space_src/sync/repo_plan"
16
+ require "space_src/sync/report"
16
17
  require "space_src/ui/reporter"
17
18
 
18
19
  module Space::Src
@@ -79,8 +80,18 @@ module Space::Src
79
80
  # Runs one sync pass.
80
81
  # @param config [Config::Config] the validated config struct
81
82
  # @param paths [Paths] the XDG paths object
82
- # @return [Dry::Monads::Result<State::Store::State>]
83
- def call(config:, paths:)
83
+ # @param prune [Boolean] drop state rows for repos this run did
84
+ # not see. Only the CALLER can know whether `config` is the
85
+ # complete tracked set or a scoped subset — the engine cannot
86
+ # tell "user tracks one repo" from "user passed --repo" — so
87
+ # this is never inferred here. Defaults to false: never delete
88
+ # unless explicitly told to. Even when true, pruning is skipped
89
+ # if any org listing failed (see below).
90
+ # @return [Dry::Monads::Result<Report>] the merged state plus the
91
+ # count of repos this run processed. The two are distinct: state
92
+ # accumulates every repo ever synced, `processed` is this run's
93
+ # work (see Sync::Report).
94
+ def call(config:, paths:, prune: false)
84
95
  Sync do |task|
85
96
  semaphore = Async::Semaphore.new(config.concurrency, parent: task)
86
97
  barrier = Async::Barrier.new
@@ -147,12 +158,24 @@ module Space::Src
147
158
  @reporter.run_finished(summary)
148
159
 
149
160
  # Phase 4: assemble new state, write once.
150
- new_state = build_new_state(state, results, org_records)
161
+ #
162
+ # A failed org listing means `discovered` is missing that
163
+ # org's repos through no fault of the user's config, so
164
+ # pruning would delete every row for a whole org on a
165
+ # transient forge outage. Only prune when the run saw the
166
+ # complete picture.
167
+ listings_complete = org_records.each_value.none?(&:last_error)
168
+ new_state = build_new_state(
169
+ state, results, org_records, prune: prune && listings_complete
170
+ )
151
171
  write_result = State::Store.write(paths.state_file, new_state)
152
172
  if write_result.failure?
153
173
  write_result
154
174
  else
155
- Dry::Monads::Success(new_state)
175
+ # results.size, not new_state.repos.size — the latter is
176
+ # the accumulated state file and would report every repo
177
+ # ever synced (553 on a scoped one-repo run).
178
+ Dry::Monads::Success(Report.new(state: new_state, processed: results.size))
156
179
  end
157
180
  ensure
158
181
  @reporter.detach
@@ -161,7 +184,11 @@ module Space::Src
161
184
 
162
185
  if lock_result == State::Lock::NOT_ACQUIRED
163
186
  warn "src: skipped — another sync in progress"
164
- Dry::Monads::Success(State::Store.load(paths.state_file).success)
187
+ # Nothing ran, so processed is 0 — the state is returned only
188
+ # so callers still get a usable snapshot.
189
+ Dry::Monads::Success(
190
+ Report.new(state: State::Store.load(paths.state_file).success, processed: 0)
191
+ )
165
192
  else
166
193
  lock_result
167
194
  end
@@ -445,8 +472,20 @@ module Space::Src
445
472
  # run's per-repo outcomes + the org records. Failures get a
446
473
  # status: error row so every processed repo has a state entry
447
474
  # (gate G8).
448
- def build_new_state(prev, results, org_records)
449
- repos = prev.repos.dup
475
+ #
476
+ # With prune: false (the default) rows for repos this run did not
477
+ # process are carried over untouched — that is what keeps a scoped
478
+ # `sync --repo` from deleting every other repo's state.
479
+ #
480
+ # With prune: true the carry-over is dropped, so the new state
481
+ # holds exactly the repos this run processed and rows for repos
482
+ # removed from config stop accumulating. Starting from an empty
483
+ # hash is equivalent to selecting the processed keys out of prev:
484
+ # every repo in the sweep contributes exactly one result tuple
485
+ # (process_one's rescue guarantees one even on an unhandled
486
+ # raise), so each gets a fresh row in the merge below.
487
+ def build_new_state(prev, results, org_records, prune: false)
488
+ repos = prune ? {} : prev.repos.dup
450
489
  results.each do |key, repo, error|
451
490
  repos[key] = (repo || State::Store::Repo.new(
452
491
  default_branch: nil,
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Space::Src
4
+ module Sync
5
+ # What one sync pass did.
6
+ #
7
+ # `state` is the merged whole-state that gets written to disk: it
8
+ # carries a row for every repo ever synced, because build_new_state
9
+ # starts from the previous state and merges this run's outcomes into
10
+ # it. `processed` counts only the repos this run actually touched.
11
+ #
12
+ # The two diverge whenever the run is scoped (`sync --repo`) or the
13
+ # state file already holds rows from earlier runs — which is why
14
+ # callers wanting "how many repos did this run handle?" must read
15
+ # `processed` and never `state.repos.size`.
16
+ Report = Data.define(:state, :processed)
17
+ end
18
+ end
data/lib/space_src.rb CHANGED
@@ -25,6 +25,7 @@ require "space_src/scm/git"
25
25
  require "space_src/forge/client"
26
26
  require "space_src/forge/github"
27
27
  require "space_src/sync/repo_plan"
28
+ require "space_src/sync/report"
28
29
  require "space_src/sync/engine"
29
30
  require "space_src/config/duration"
30
31
  require "space_src/log_rotator"
@@ -170,8 +170,20 @@ re-prints them, or `git show <freeze-sha>:architecture/I<NN>-<name>.md`). For ea
170
170
  gate: run the gate command yourself — `architect gate <iteration>` runs the
171
171
  frozen gate commands in the resolved repo/worktree and streams raw output (it is a
172
172
  runner, never a judge), or run them by hand — then compare the output against the
173
- verbatim frozen text → **PASS / FAIL / INVALID** (INVALID = not measured the way
174
- the gate specifies). Check `git diff <freeze-sha> HEAD --
173
+ verbatim frozen text → **PASS / FAIL / INVALID**. The line between the failing
174
+ two is the mechanism. **FAIL**: the instrument measured exactly what the gate
175
+ says, and the answer was wrong. **INVALID**: the instrument did not measure what
176
+ the prose AC asserts — a quoting error, a `2>&1` capturing unrelated stderr, a
177
+ command that dies in the runner's shell. INVALID is a legitimate, expected
178
+ outcome, not a loop failure: prove the property directly with evidence gathered
179
+ this session, and carry the corrected gate into the next iteration's freeze
180
+ rather than leaving the defect to recur. It is never a loosening route — a
181
+ threshold the results simply missed is FAIL, however near. A lane that raised a
182
+ **letter-versus-spirit conflict** (per the lane-prompt clause: it built the AC's
183
+ stated property rather than degrade the artifact to fit its letter, and reported
184
+ the conflict with evidence) has done its job — the defect is the criterion's
185
+ authoring (§4), and the lane is not penalized for it; rule on the conflict like
186
+ any disagreement. Check `git diff <freeze-sha> HEAD --
175
187
  architecture/I<NN>-<name>.md` — any change to Grounds/Specification/Acceptance
176
188
  Criteria lines is an automatic FAIL.
177
189
  Gate-pass is necessary, not sufficient: read the diff against the
@@ -265,26 +277,43 @@ contract, self-contained:
265
277
  its **target repo + file-touch set, checked for overlap**: name the repo
266
278
  (`repos/<repo>`) and every file each lane may touch. The machine-readable
267
279
  declaration lives in a fenced ` ```lanes ` block in the Specification — one
268
- entry per lane (`name`, `repo`, `touch` globs) the single frozen source of
280
+ entry per lane (`name`, `repo`, `touch` globs; optionally `allowed_tools`/
281
+ `append_allowed_tools` to replace/extend the lane's builder tool grant — see
282
+ `dispatch.md`) — the single frozen source of
269
283
  truth `architect freeze` records into `space.yaml` and `architect provision`
270
- materializes. The touch-set now lives *with* the frozen spec by design: it
271
- closes the drift where a `worktree add --touch` flag could diverge from the
272
- spec's intent. The scaffold ships a commented ` ```lanes ` stub in the
273
- Specification (see `templates/iteration.md.erb`) uncomment it. Lanes in
284
+ materializes. The same boundary is stated twice more the lane-prompt's
285
+ may-touch list, and the scope gate that checks the lane's diff at judge
286
+ time and all three must be one exhaustive enumeration: every file listed,
287
+ no glob standing in for the list; the scope gate's pattern is that
288
+ enumeration exactly; the prompt's list is generated from it. Consistency is
289
+ not completeness — three statements drawn from one too-narrow search agree
290
+ and are wrong together — so before freezing a criterion that requires an
291
+ identifier to change name, grep the whole repo for it and confirm every hit
292
+ falls inside some lane's touch set; `architect rehearse` reports this
293
+ asymmetry, so the check is read, not remembered. Either defect surfaces
294
+ only after the freeze, where hard rule 3 forbids widening the declaration:
295
+ `integrate` refuses a correct lane, or the stray hit breaks a file no lane
296
+ may legally fix. The scaffold ships a
297
+ commented ` ```lanes ` stub in the Specification (see
298
+ `templates/iteration.md.erb`) — uncomment it. Lanes in
274
299
  *different* repos are inherently disjoint; same-repo lanes with any file
275
300
  overlap run as one. Each lane gets its own objective, output format, and
276
301
  boundaries. Most
277
302
  iterations are one lane — fan out only when the work is genuinely parallel (a
278
- cross-repo project often is). Two first-class patterns — runnable recipes in `dispatch.md`: **parallel +
303
+ cross-repo project often is). Three first-class patterns — runnable recipes in `dispatch.md`: **parallel +
279
304
  fast-follow** (disjoint lanes integrate first; a fast-follow lane off
280
- `project/<slug>` carries the seam — see `### Parallel + fast-follow`) and
305
+ `project/<slug>` carries the seam — see `### Parallel + fast-follow`),
281
306
  **serial deferred judgment** (iterations run to gates-green with `architect
282
307
  verdict` withheld; one later batch session judges each against its own frozen
283
- AC — see `### Serial deferred judgment`).
284
- - **Effort call** thinking budget set in the lane-prompt via the escalation
285
- keywords (`think hard` `ultrathink`); default unattended builder work high,
286
- downgrade a routine, tightly-specified lane (record which and why). Claude
287
- Code has no per-invocation effort flag see `dispatch.md`.
308
+ AC — see `### Serial deferred judgment`), and **long-running sweep** (a lane
309
+ launches its own long detached process, ends its session at a sanctioned
310
+ pending status, and a later session in the same worktree audits it once it
311
+ exits see `### Long-running sweep` in `dispatch.md`).
312
+ - **Effort call** thinking budget set per dispatch with `architect dispatch
313
+ --effort <level>`, translated and clamped to the lane's harness (the
314
+ escalation keywords `think hard` … `ultrathink` still work in-prompt);
315
+ default unattended builder work high, downgrade a routine, tightly-specified
316
+ lane (record which and why). Levels and mechanics: `dispatch.md`.
288
317
 
289
318
  **Spike (probe) iterations.** When the open question is too uncertain for a
290
319
  build — the repo can't answer it and routine API-verification won't resolve it
@@ -307,7 +336,61 @@ through the normal builder/lane machinery.
307
336
  Then write the **Acceptance Criteria** section — prose conditions (AC1, AC2, …)
308
337
  that the architect judges against, followed by a fenced ` ```gates ` block of
309
338
  runnable checks (each gate carries `id`, `ac`, `cmd`, and `expect`; `cwd` is
310
- optional) and run `architect freeze <name>`. What must be frozen before
339
+ optional). Calibrate each criterion's precision to the property it asserts
340
+ R4 ("grade the outcome, not the path") applied at authoring time, the only time
341
+ it can be applied, because a frozen criterion is never loosened after results.
342
+ Two kinds of criterion, two calibrations:
343
+
344
+ - A criterion asserting a **property that must hold** is bounded from one side
345
+ only — the side the defect is on — freezing a floor, not an equality, so an
346
+ addition or change the lane discovers stays legal. "Exactly eight runtime
347
+ dependencies" freezes today's snapshot and outlaws the ninth a transitive
348
+ need later forces; the property it stood in for was "declares everything it
349
+ requires."
350
+ - A criterion whose **number is itself the deliverable** may be exact — there
351
+ the precision is the point (a touch-set boundary, a wire-format constant).
352
+
353
+ Writing the first kind as the second is the defect: it leaves the lane no
354
+ sanctioned path except degrading the artifact to fit the number.
355
+
356
+ Say, too, what each gate proves: a presence-grep gate on prose is a
357
+ **tripwire**, never the proof — it shows a keyword landed, not that the thing
358
+ works — and its criterion should say which it is, leaving the substance
359
+ architect-read.
360
+
361
+ **Pre-freeze check** — run against the drafted AC in the minutes before
362
+ `architect freeze`, while a bad criterion still costs nothing:
363
+
364
+ - **snapshot** — no AC or gate hard-codes a count, name-set, or byte-identity
365
+ that is merely what was known at freeze time;
366
+ - **control** — every baseline or regression claim had its control actually run
367
+ in this session, not recalled or assumed;
368
+ - **mechanism** — no AC names a *how* (an API signature, a helper, a file
369
+ layout) where the *what* is the requirement, such that a better shape the
370
+ lane discovers would be forbidden;
371
+ - **interface** — any CLI surface a Specification names — a verb, a flag
372
+ spelling, a subcommand — is executed once before it is frozen: a frozen
373
+ interface is only safe if it is implementable, which is the assumption
374
+ every code/prose lane split rests on. No verb backs this item yet — unlike
375
+ dry-run, it is a discipline run by hand;
376
+ - **dry-run** — `architect rehearse <name>`: it runs the drafted gates from
377
+ the working tree through the same execution path `architect gate` uses
378
+ (`/bin/sh`, same run-dir resolution, same evaluator) and classifies each —
379
+ **RED**, a clean non-zero: the gate discriminates, the healthy pre-freeze
380
+ result; **GREEN**, passes on base: a declared regression guard, or a gate
381
+ that measures nothing; **BROKEN**, a 127 / syntax error / timeout —
382
+ advisory, because a correct RED can look broken (`grep -q x` on a file the
383
+ lane will write exits 2): the tool names the suspicion, you confirm;
384
+ **EMPTY**, no gates or an untouched placeholder. `--record` emits a
385
+ paste-able provenance block for the AC preamble. It reports; which GREENs
386
+ are guards and which are defects stays your call.
387
+
388
+ Then run `architect freeze <name>`. Freeze requires a fresh rehearsal stamp —
389
+ `rehearse` records one in `space.yaml`, keyed to the gates block's content, so
390
+ editing a gate afterward stales it; `architect freeze --skip-rehearse REASON`
391
+ (non-empty, recorded) is the escape valve. The stamp records that you looked,
392
+ never that gates passed — an all-RED and an all-GREEN run stamp identically.
393
+ What must be frozen before
311
394
  dispatch is the Acceptance Criteria: `architect freeze` lints the gates block
312
395
  (absent or empty gates is allowed but warns; malformed fails), commits any
313
396
  pending content in the frozen region (Grounds/Specification/Acceptance Criteria)
@@ -319,12 +402,17 @@ once a frozen section changed afterward.
319
402
 
320
403
  ### 5. Dispatch (one fresh `claude -p` per lane, worktree-isolated)
321
404
 
322
- Per the mechanics in `dispatch.md`. The lane lifecycle is **declare → freeze →
323
- provision → write prompts → dispatch** — every lane gets a worktree; there is no
324
- dispatch-in-the-checkout path:
405
+ Per the mechanics in `dispatch.md`. The lane lifecycle is **declare → rehearse
406
+ → freeze → provision → write prompts → dispatch** — every lane gets a worktree;
407
+ there is no dispatch-in-the-checkout path:
325
408
 
326
409
  - **Declare** — at spec time, each lane is one entry in the Specification's
327
- fenced ` ```lanes ` block (§4): `name`, `repo`, `touch` globs.
410
+ fenced ` ```lanes ` block (§4): `name`, `repo`, `touch` globs, and optionally
411
+ `allowed_tools`/`append_allowed_tools`.
412
+ - **Rehearse** — `architect rehearse <iteration>` dry-runs the drafted gates
413
+ from the working tree (§4's pre-freeze check), resolving its run dir from the
414
+ drafted ` ```lanes ` block, and stamps `space.yaml`; `freeze` refuses without
415
+ a fresh stamp.
328
416
  - **Freeze** — `architect freeze` parses that block and records each lane
329
417
  (name, repo, touch_set) into `space.yaml`.
330
418
  - **Provision** — `architect provision <iteration>` materializes every declared
@@ -406,7 +494,10 @@ only the per-lane `lane/<iteration>-<lane>` branches and worktrees, never the
406
494
  project branch. Update the iteration index in `architecture/ARCHITECT.md`
407
495
  (recording the `project/<slug>` branch), remove the provisioned worktrees
408
496
  (`architect integrate … --teardown`, or `architect worktree remove <iteration>
409
- <lane>`), and commit the space.
497
+ <lane>`), and commit the space. Both refuse per lane when its worktree still
498
+ holds uncommitted work — untracked files included; `--force` overrides and
499
+ discards it. The `--lanes` integration path itself is unaffected, since
500
+ `integrate` commits each lane's work before teardown.
410
501
 
411
502
  **Run the frozen gates cold** — `architect gate <iteration>` runs the frozen
412
503
  gate commands against the integration tree and streams raw output (a runner, not