space-architect 2.0.2 → 4.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.
@@ -6,7 +6,7 @@ require "dry/cli"
6
6
  # on each occurrence, so `-r a -r b` yields ["b"]. We want repeated flags to
7
7
  # accumulate (`-r a -r b -r c` => ["a", "b", "c"]) the way git/docker-style CLIs
8
8
  # do, while still accepting the comma form. dry-cli exposes no hook for this, so
9
- # we reopen two private seams, each mirroring dry-cli 1.4.1 with a single change:
9
+ # we prepend two private seams, each mirroring dry-cli 1.4.1 with a single change:
10
10
  #
11
11
  # * Parser.call — concat instead of assign for array options.
12
12
  # * Banner.extended_command_options — drop the "=VALUE1,VALUE2,.." hint that
@@ -14,62 +14,71 @@ require "dry/cli"
14
14
  # matching how you actually type it (-r VALUE).
15
15
  #
16
16
  # These mirror the released 1.4.1 source EXACTLY (not the dry-rb main branch,
17
- # which already differs). Pinned via `~> 1.4`; if a future dry-cli reworks these
18
- # methods, repeatable_options_test goes red and we re-sync. Rationale:
19
- # notes/ruby-cli-gems-report.md.
17
+ # which already differs), prepended onto the module's singleton class so `super`
18
+ # reaches dry-cli's own implementation instead of discarding it outright. Pinned
19
+ # via `~> 1.4`; if a future dry-cli reworks these methods, repeatable_options_test
20
+ # goes red and we re-sync. Rationale: notes/ruby-cli-gems-report.md.
20
21
  module Dry
21
22
  class CLI
22
23
  module Parser
23
- def self.call(command, arguments, prog_name)
24
- original_arguments = arguments.dup
25
- parsed_options = {}
24
+ # Reopened on Parser's own nesting (not CLI's) so `Result` resolves
25
+ # lexically, exactly as it does in dry-cli's own `Parser.call`.
26
+ module RepeatableOptionsPatch
27
+ def call(command, arguments, prog_name)
28
+ original_arguments = arguments.dup
29
+ parsed_options = {}
26
30
 
27
- OptionParser.new do |opts|
28
- command.options.each do |option|
29
- opts.on(*option.parser_options) do |value|
30
- if option.array?
31
- (parsed_options[option.name.to_sym] ||= []).concat(value)
32
- else
33
- parsed_options[option.name.to_sym] = value
31
+ OptionParser.new do |opts|
32
+ command.options.each do |option|
33
+ opts.on(*option.parser_options) do |value|
34
+ if option.array?
35
+ (parsed_options[option.name.to_sym] ||= []).concat(value)
36
+ else
37
+ parsed_options[option.name.to_sym] = value
38
+ end
34
39
  end
35
40
  end
36
- end
37
41
 
38
- opts.on_tail("-h", "--help") do
39
- return Result.help
40
- end
41
- end.parse!(arguments)
42
+ opts.on_tail("-h", "--help") do
43
+ return Result.help
44
+ end
45
+ end.parse!(arguments)
42
46
 
43
- parsed_options = command.default_params.merge(parsed_options)
44
- parse_required_params(command, arguments, prog_name, parsed_options)
45
- rescue ::OptionParser::ParseError, ValueError
46
- Result.failure("ERROR: \"#{prog_name}\" was called with arguments \"#{original_arguments.join(" ")}\"")
47
+ parsed_options = command.default_params.merge(parsed_options)
48
+ parse_required_params(command, arguments, prog_name, parsed_options)
49
+ rescue ::OptionParser::ParseError, ValueError
50
+ Result.failure("ERROR: \"#{prog_name}\" was called with arguments \"#{original_arguments.join(" ")}\"")
51
+ end
47
52
  end
53
+ singleton_class.prepend(RepeatableOptionsPatch)
48
54
  end
49
55
 
50
56
  module Banner
51
- def self.extended_command_options(command)
52
- result = command.options.map do |option|
53
- name = Inflector.dasherize(option.name)
54
- name = if option.boolean?
55
- "[no-]#{name}"
56
- elsif option.flag?
57
- name
58
- else
59
- # array options included: repeated flags accumulate, so show
60
- # the single repeatable form rather than "=VALUE1,VALUE2,..".
61
- "#{name}=VALUE"
62
- end
63
- name = "#{name}, #{option.alias_names.join(", ")}" if option.aliases.any?
64
- name = " --#{name.ljust(30)}"
65
- name = "#{name} # #{option.desc}"
66
- name = "#{name}, default: #{option.default.inspect}" unless option.default.nil?
67
- name
68
- end
57
+ module RepeatableOptionsPatch
58
+ def extended_command_options(command)
59
+ result = command.options.map do |option|
60
+ name = Inflector.dasherize(option.name)
61
+ name = if option.boolean?
62
+ "[no-]#{name}"
63
+ elsif option.flag?
64
+ name
65
+ else
66
+ # array options included: repeated flags accumulate, so show
67
+ # the single repeatable form rather than "=VALUE1,VALUE2,..".
68
+ "#{name}=VALUE"
69
+ end
70
+ name = "#{name}, #{option.alias_names.join(", ")}" if option.aliases.any?
71
+ name = " --#{name.ljust(30)}"
72
+ name = "#{name} # #{option.desc}"
73
+ name = "#{name}, default: #{option.default.inspect}" unless option.default.nil?
74
+ name
75
+ end
69
76
 
70
- result << " --#{"help, -h".ljust(30)} # Print this help"
71
- result.join("\n")
77
+ result << " --#{"help, -h".ljust(30)} # Print this help"
78
+ result.join("\n")
79
+ end
72
80
  end
81
+ singleton_class.prepend(RepeatableOptionsPatch)
73
82
  end
74
83
  end
75
84
  end
@@ -5,28 +5,69 @@ class Status < BaseCommand
5
5
  desc "Set a space status: active, paused, done, archived"
6
6
  argument :rest, type: :array, required: false, desc: "[SPACE] STATUS"
7
7
 
8
+ # One command, overloaded by its positional(s):
9
+ # help token (`help`, or dry-cli's -h/--help which never reach here) → help
10
+ # lone keyword / `<space> <keyword>` → set
11
+ # bare, or a lone non-keyword identifier → report
8
12
  def call(rest: [], **opts)
9
13
  setup_terminal(**opts.slice(:color, :colors))
14
+ args = Array(rest)
15
+ return show_help if help_token?(args)
16
+
10
17
  handle_errors do
11
- identifier, status_value = parse_status_args(Array(rest))
12
- render(store.find(identifier)) do |space|
13
- space.update_status(status_value)
14
- terminal.success "#{space.id} is #{space.status}"
15
- CLI.record_outcome(Outcome.new(exit_code: 0))
18
+ if set_args?(args)
19
+ set_status(args)
20
+ elsif args.length <= 1
21
+ report(args.first)
22
+ else
23
+ raise Space::Core::Error, "Usage: space status [SPACE] STATUS"
16
24
  end
17
25
  end
18
26
  end
19
27
 
20
28
  private
21
29
 
22
- def parse_status_args(args)
23
- case args.length
24
- when 1
25
- [nil, args.first]
26
- when 2
27
- args
28
- else
29
- raise Space::Core::Error, "Usage: space status [SPACE] STATUS"
30
+ # The bare word `help` shows the command help, like dry-cli's -h/--help (which
31
+ # it intercepts before dispatch). It must never set status to "help" or report.
32
+ def help_token?(args)
33
+ args == ["help"]
34
+ end
35
+
36
+ def show_help
37
+ out.puts Dry::CLI::Banner.call(self.class, Dry::CLI::ProgramName.call(["status"]))
38
+ CLI.record_outcome(Outcome.new(exit_code: 0))
39
+ end
40
+
41
+ # A set request: `<space> <keyword>` (two args), or a lone status keyword. A
42
+ # lone non-keyword arg is a space identifier to REPORT, not a malformed status.
43
+ def set_args?(args)
44
+ args.length == 2 ||
45
+ (args.length == 1 && Space::Core::Space::VALID_STATUSES.include?(args.first))
46
+ end
47
+
48
+ def set_status(args)
49
+ identifier, status_value = args.length == 2 ? args : [nil, args.first]
50
+ render(store.find(identifier)) do |space|
51
+ space.update_status(status_value)
52
+ terminal.success "#{space.id} is #{space.status}"
53
+ CLI.record_outcome(Outcome.new(exit_code: 0))
54
+ end
55
+ end
56
+
57
+ def report(identifier)
58
+ render(store.find(identifier)) do |space|
59
+ terminal.say "ID: #{space.id}"
60
+ terminal.say "Title: #{space.title}"
61
+ terminal.say "Status: #{space.status}"
62
+ terminal.say "Path: #{terminal.path(space.path)}"
63
+ terminal.say "Created: #{space.data['created_at']}"
64
+ terminal.say "Updated: #{space.data['updated_at']}"
65
+ lines = LoopStatus.lines(space.data["project"])
66
+ if lines
67
+ terminal.say ""
68
+ lines.each { |line| terminal.say line }
69
+ end
70
+ CLI.record_outcome(Outcome.new(exit_code: 0))
30
71
  end
31
72
  end
32
73
  end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Space::Core
4
+ module Commands
5
+ module_function
6
+
7
+ # Render a multi-flag command with trailing " \" continuations broken at
8
+ # "--flag" boundaries, continuation lines indented two spaces. Commands
9
+ # without "--" flags are returned unchanged.
10
+ def wrap(command)
11
+ parts = command.split(/(?= --)/)
12
+ return command if parts.size <= 1
13
+
14
+ parts.each_with_index.map do |part, i|
15
+ segment = i.zero? ? part.rstrip : " #{part.lstrip}"
16
+ i < parts.size - 1 ? "#{segment} \\" : segment
17
+ end.join("\n")
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Space::Core
4
+ module Paths
5
+ module_function
6
+
7
+ def contract(path, env: ENV)
8
+ value = path.to_s
9
+ home = XDG.home(env: env)
10
+ [home, realpath_or_nil(home)].compact.uniq.each do |h|
11
+ return "~" if value == h
12
+ return "~#{value.delete_prefix(h)}" if value.start_with?("#{h}/")
13
+ end
14
+ value
15
+ end
16
+
17
+ def realpath_or_nil(path)
18
+ File.realpath(path)
19
+ rescue SystemCallError
20
+ nil
21
+ end
22
+ end
23
+ end
@@ -295,7 +295,7 @@ module Space::Core
295
295
  complete -c space -f -n "__space_architect_complete_needs_command" -a path -d "Print a space path"
296
296
  complete -c space -f -n "__space_architect_complete_needs_command" -a use -d "Select and cd to a space with fish integration"
297
297
  complete -c space -f -n "__space_architect_complete_needs_command" -a current -d "Show the current space"
298
- complete -c space -f -n "__space_architect_complete_needs_command" -a status -d "Set a space status"
298
+ complete -c space -f -n "__space_architect_complete_needs_command" -a status -d "Report or set a space status"
299
299
  complete -c space -f -n "__space_architect_complete_needs_command" -a config -d "Show or update config"
300
300
  complete -c space -f -n "__space_architect_complete_needs_command" -a repo -d "Manage repos in the current space"
301
301
  complete -c space -f -n "__space_architect_complete_needs_command" -a repos -d "Manage repos in the current space"
@@ -9,6 +9,11 @@ module Space::Core
9
9
  METADATA_FILE = "space.yaml"
10
10
  VALID_STATUSES = %w[active paused done archived].freeze
11
11
 
12
+ # Canonical space.yaml shape: registry under `project:` (renamed from the
13
+ # pre-2.0 `architect:` key). Bumped from 1 in the release that did the
14
+ # rename, since that release shipped no read-side alias for it.
15
+ SCHEMA_VERSION = 2
16
+
12
17
  attr_reader :path, :data
13
18
 
14
19
  def self.load(path)
@@ -18,13 +23,58 @@ module Space::Core
18
23
  parsed = YAML.safe_load(metadata_path.read, aliases: false) || {}
19
24
  raise Error, "Space metadata must contain a YAML mapping: #{metadata_path}" unless parsed.is_a?(Hash)
20
25
 
21
- new(Pathname.new(path), stringify_keys(parsed))
26
+ data = stringify_keys(parsed)
27
+ normalize_schema!(data, metadata_path)
28
+ new(Pathname.new(path), data)
22
29
  end
23
30
 
24
31
  def self.stringify_keys(hash)
25
32
  hash.each_with_object({}) { |(key, value), result| result[key.to_s] = value }
26
33
  end
27
34
 
35
+ # Normalizes a parsed space.yaml hash to canonical schema v2, in place:
36
+ # - future version (> SCHEMA_VERSION) → raise, refuse to misread it.
37
+ # - `architect:` only (v1a) → becomes `project:`.
38
+ # - `project:` only (v1b or v2) → left as-is.
39
+ # - both present, `project:` empty and `architect:` non-empty → the
40
+ # corruption from the old silent-default read path; take `architect:`.
41
+ # - both present and non-empty: identical → keep; differing → raise
42
+ # rather than silently pick one and lose data.
43
+ # Idempotent, so a canonical v2 space is a no-op through this method.
44
+ def self.normalize_schema!(data, metadata_path)
45
+ version = data["version"]
46
+ if version.is_a?(Integer) && version > SCHEMA_VERSION
47
+ raise Error,
48
+ "space.yaml schema version #{version} is newer than this gem supports " \
49
+ "(#{SCHEMA_VERSION}); upgrade space-architect: #{metadata_path}"
50
+ end
51
+
52
+ legacy = data.delete("architect")
53
+ current = data["project"]
54
+
55
+ data["project"] =
56
+ if legacy.nil?
57
+ current
58
+ elsif current.nil? || empty_project_block?(current)
59
+ legacy
60
+ elsif legacy == current
61
+ current
62
+ else
63
+ raise Error,
64
+ "#{metadata_path} has both 'architect:' and 'project:' blocks with " \
65
+ "conflicting content; resolve manually.\narchitect: #{legacy.inspect}\n" \
66
+ "project: #{current.inspect}"
67
+ end
68
+
69
+ data["version"] = SCHEMA_VERSION
70
+ end
71
+ private_class_method :normalize_schema!
72
+
73
+ def self.empty_project_block?(block)
74
+ block.is_a?(Hash) && Array(block["iterations"]).empty? && block["current_iteration"].nil?
75
+ end
76
+ private_class_method :empty_project_block?
77
+
28
78
  def initialize(path, data)
29
79
  @path = Pathname.new(path)
30
80
  @data = data
@@ -232,7 +232,7 @@ module Space::Core
232
232
  def metadata_for(id:, title:, timestamp:)
233
233
  iso_timestamp = timestamp.iso8601
234
234
  {
235
- "version" => 1,
235
+ "version" => 2,
236
236
  "id" => id,
237
237
  "title" => title,
238
238
  "status" => "active",
@@ -48,13 +48,7 @@ module Space::Core
48
48
  end
49
49
 
50
50
  def path(path)
51
- value = path.to_s
52
- homes.each do |home|
53
- return "~" if value == home
54
- return "~#{value.delete_prefix(home)}" if value.start_with?("#{home}/")
55
- end
56
-
57
- value
51
+ Paths.contract(path, env: config.env)
58
52
  end
59
53
 
60
54
  def with_spinner(message)
@@ -108,17 +102,6 @@ module Space::Core
108
102
  end
109
103
  end
110
104
 
111
- def homes
112
- home = XDG.home(env: config.env)
113
- [home, realpath_or_nil(home)].compact.uniq
114
- end
115
-
116
- def realpath_or_nil(path)
117
- File.realpath(path)
118
- rescue SystemCallError
119
- nil
120
- end
121
-
122
105
  def table_row(headers, row, column_widths, header: false)
123
106
  row.each_with_index.map do |cell, index|
124
107
  raw = cell.to_s
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Space
4
4
  module Core
5
- VERSION = "2.0.2"
5
+ VERSION = "4.0.0"
6
6
  end
7
7
  end
data/lib/space_core.rb CHANGED
@@ -10,6 +10,8 @@ require "space_core/warnings"
10
10
  Space::Core::Warnings.disable_experimental!
11
11
  require "space_core/atomic_write"
12
12
  require "space_core/xdg"
13
+ require "space_core/paths"
14
+ require "space_core/commands"
13
15
  require "space_core/config"
14
16
  require "space_core/state"
15
17
  require "space_core/slugger"
@@ -2,7 +2,6 @@
2
2
 
3
3
  require "pastel"
4
4
  require "dry/monads"
5
- require "space_src/cli"
6
5
  require "space_src/cli/options"
7
6
  require "space_src/cloner"
8
7
 
@@ -2,7 +2,6 @@
2
2
 
3
3
  require "pastel"
4
4
  require "yaml"
5
- require "space_src/cli"
6
5
  require "space_src/ui/mode"
7
6
  require "space_src/cli/options"
8
7
 
@@ -3,7 +3,6 @@
3
3
  require "pastel"
4
4
  require "fileutils"
5
5
  require "dry/monads"
6
- require "space_src/cli"
7
6
  require "space_src/ui/mode"
8
7
  require "space_src/cli/options"
9
8
  require "space_src/launchd/agent"
@@ -2,7 +2,6 @@
2
2
 
3
3
  require "pastel"
4
4
  require "dry/monads"
5
- require "space_src/cli"
6
5
  require "space_src/ui/mode"
7
6
  require "space_src/cli/options"
8
7
 
@@ -2,7 +2,6 @@
2
2
 
3
3
  require "pastel"
4
4
  require "dry/monads"
5
- require "space_src/cli"
6
5
  require "space_src/ui/mode"
7
6
  require "space_src/cli/options"
8
7
 
@@ -1,6 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "space_src/cli"
4
3
  require "space_src/shell_integration"
5
4
 
6
5
  module Space::Src
@@ -1,7 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "pastel"
4
- require "space_src/cli"
5
4
  require "space_src/ui/mode"
6
5
  require "space_src/cli/options"
7
6
 
@@ -1,7 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "dry/monads"
4
- require "space_src/cli"
5
4
  require "space_src/cli/repo" # for Repo::Helpers.parse_ref
6
5
  require "space_src/cli/options"
7
6
  require "space_src/ui/mode"
data/lib/space_src/cli.rb CHANGED
@@ -1,7 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "dry/cli"
4
- require "space_src"
5
4
  require "space_src/migration"
6
5
 
7
6
  module Space::Src
@@ -110,6 +110,7 @@ loop.
110
110
  8. **Stop conditions:** failing verification you can't root-cause, instructions
111
111
  conflicting with project docs, irreversible/destructive calls, or scope
112
112
  growth beyond the iteration → checkpoint to the handoff and ask the human.
113
+ For bugs in the architect tooling or process itself, run `architect bug-report` — it prints a prefilled issue template and the `gh` command to file it.
113
114
 
114
115
  ## Procedure
115
116
 
@@ -120,8 +121,11 @@ loop.
120
121
  gate (test/lint/typecheck/build commands) from docs or CI config.
121
122
  - Once per environment: `claude --version` and confirm the builder model
122
123
  resolves (`echo ok | claude -p --model <builder-model> --max-turns 1`;
123
- details in `dispatch.md`). First dispatch in a new environment is a canary —
124
- confirm it starts cleanly before fanning out.
124
+ details in `dispatch.md`). Past that one-time check, every foreground dispatch
125
+ self-verifies it prints a liveness line naming the streamed model and
126
+ confirming the run log is growing (a WARN line instead if the streamed model
127
+ disagrees with the pin or the log isn't growing) — so no lone lane needs
128
+ launching-and-watching before the fan-out.
125
129
  - Read `architecture/ARCHITECT.md` (the cross-iteration table of contents),
126
130
  `architecture/BRIEF.md` if present (the durable §-numbered project contract you
127
131
  cite as BRIEF §N), and the iteration file `architecture/I<NN>-<name>.md` for any
@@ -244,9 +248,17 @@ contract, self-contained:
244
248
  task.
245
249
  - **Lane plan** — split the iteration into 1–4 parallel lanes, each declaring
246
250
  its **target repo + file-touch set, checked for overlap**: name the repo
247
- (`repos/<repo>`) and every file each lane may touch. Lanes in *different*
248
- repos are inherently disjoint; same-repo lanes with any file overlap run as
249
- one. Each lane gets its own objective, output format, and boundaries. Most
251
+ (`repos/<repo>`) and every file each lane may touch. The machine-readable
252
+ declaration lives in a fenced ` ```lanes ` block in the Specification — one
253
+ entry per lane (`name`, `repo`, `touch` globs) the single frozen source of
254
+ truth `architect freeze` records into `space.yaml` and `architect provision`
255
+ materializes. The touch-set now lives *with* the frozen spec by design: it
256
+ closes the drift where a `worktree add --touch` flag could diverge from the
257
+ spec's intent. The scaffold ships a commented ` ```lanes ` stub in the
258
+ Specification (see `templates/iteration.md.erb`) — uncomment it. Lanes in
259
+ *different* repos are inherently disjoint; same-repo lanes with any file
260
+ overlap run as one. Each lane gets its own objective, output format, and
261
+ boundaries. Most
250
262
  iterations are one lane — fan out only when the work is genuinely parallel (a
251
263
  cross-repo project often is). Two first-class patterns — runnable recipes in `dispatch.md`: **parallel +
252
264
  fast-follow** (disjoint lanes integrate first; a fast-follow lane off
@@ -292,13 +304,24 @@ once a frozen section changed afterward.
292
304
 
293
305
  ### 5. Dispatch (one fresh `claude -p` per lane, worktree-isolated)
294
306
 
295
- Per the mechanics in `dispatch.md`:
296
-
297
- - **1 lane** → dispatch in the target repo's checkout (`repos/<repo>`).
298
- - **2–4 lanes** → `architect worktree add <repo> <iteration> <lane>
299
- [--base <repo-base>]` per lane (creates `build/<id>-<lane>/wt` off the target
300
- repo's base commit a repo commit, distinct from the freeze, which is a
301
- space commit — and records it in `space.yaml`).
307
+ Per the mechanics in `dispatch.md`. The lane lifecycle is **declare → freeze →
308
+ provision → write prompts → dispatch** — every lane gets a worktree; there is no
309
+ dispatch-in-the-checkout path:
310
+
311
+ - **Declare** at spec time, each lane is one entry in the Specification's
312
+ fenced ` ```lanes ` block (§4): `name`, `repo`, `touch` globs.
313
+ - **Freeze**`architect freeze` parses that block and records each lane
314
+ (name, repo, touch_set) into `space.yaml`.
315
+ - **Provision** — `architect provision <iteration>` materializes every declared
316
+ lane's worktree + `lane/<id>-<lane>` branch in one shot, each off the resolved
317
+ base (`--base` override, else `project/<slug>` when it exists, else the repo's
318
+ default branch — a repo commit, distinct from the freeze, which is a space
319
+ commit) and recorded in `space.yaml`; it is idempotent, and `--lane <name>`
320
+ provisions a single lane. The manual `architect worktree add` it wraps stays
321
+ registered as an internal primitive for edge cases — not a step you run.
322
+ - `dispatch`, `integrate`, and `gate` auto-materialize a lane whose worktree is
323
+ missing from its frozen declaration, so a lane you didn't pre-provision can't
324
+ dead-end the flow.
302
325
 
303
326
  Assemble each lane's lane-prompt (the template in `dispatch.md` + this lane's
304
327
  section of the Specification + the frozen Acceptance Criteria) and write it to
@@ -362,9 +385,9 @@ it (never hand-resolve). A cross-repo project yields one `project/<slug>` branch
362
385
  per touched repo. `main` is never touched per-iteration — `--teardown` deletes
363
386
  only the per-lane `lane/<iteration>-<lane>` branches and worktrees, never the
364
387
  project branch. Update the iteration index in `architecture/ARCHITECT.md`
365
- (recording the `project/<slug>` branch), remove the worktrees (`architect
366
- integrate … --teardown`, or `architect worktree remove <iteration> <lane>`), and
367
- commit the space.
388
+ (recording the `project/<slug>` branch), remove the provisioned worktrees
389
+ (`architect integrate … --teardown`, or `architect worktree remove <iteration>
390
+ <lane>`), and commit the space.
368
391
 
369
392
  **Run the frozen gates cold** — `architect gate <iteration>` runs the frozen
370
393
  gate commands against the integration tree and streams raw output (a runner, not
@@ -373,10 +396,15 @@ cited BRIEF §sections (per §2), then write the **Verdict** (`architect verdict
373
396
  <iteration> continue|kill --from <file>`): disagreement rulings, per-AC
374
397
  PASS/FAIL/INVALID, the KILL/CONTINUE call.
375
398
 
376
- At project end, `architect land` prints the single `gh pr create --base main
377
- --head project/<slug>` command per touched repo and writes a PR body to
378
- `build/land/`no push, no `gh` call; the human runs it from the repo when
379
- the project is ready to ship.
399
+ At project end, landing is yours, not the CLI's the PR body is judgment
400
+ output, the same class as a Verdict. Per touched repo: write the PR body
401
+ yourselffrom the iteration verdicts, the integrated diff, and the BRIEF
402
+ to `build/land/<repo>-pr-body.md`, then present the paste-and-run block to the
403
+ human: `cd` to the repo checkout, `git push -u origin project/<slug>`, and
404
+ `gh pr create --base main --head project/<slug> --title … --body-file …` —
405
+ paths `~`-contracted, the multi-flag command broken with trailing ` \` at flag
406
+ boundaries. No push, no `gh` call by you or the CLI; the human runs the block
407
+ when the project is ready to ship.
380
408
 
381
409
  ## Maintenance
382
410