space-architect 4.0.0 → 5.1.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.
@@ -15,6 +15,36 @@ module Space::Architect
15
15
 
16
16
  @phase = [order, label]
17
17
  end
18
+
19
+ # Declares -m/--message and --message-from on a committing command. Every
20
+ # loop command that commits takes both: the space's git log is the loop's
21
+ # durable memory, so detailed messages are encouraged everywhere.
22
+ def self.commit_message_options
23
+ option :message, aliases: ["-m"], default: nil,
24
+ desc: "Commit message: first line completes the subject after the canonical prefix, the rest becomes the body"
25
+ option :message_from, default: nil,
26
+ desc: "Read the commit message from this file (subject line + detailed body)"
27
+ end
28
+
29
+ private
30
+
31
+ # Authored-content intake shared by section/verdict/brief: a file, an
32
+ # inline flag, or stdin — canonical files are only ever written by the CLI.
33
+ def read_body(from: nil, body: nil, stdin: false, what: "section body")
34
+ return File.read(from) if from
35
+ return body if body
36
+ return $stdin.read if stdin
37
+
38
+ raise Space::Core::Error, "provide the #{what} via --from <file>, --body <text>, or --stdin"
39
+ end
40
+
41
+ # Commit-message intake for commit_message_options: --message-from wins
42
+ # over -m/--message; nil means the command's canonical default message.
43
+ def read_commit_message(message: nil, message_from: nil)
44
+ return File.read(message_from) if message_from
45
+
46
+ message
47
+ end
18
48
  end
19
49
 
20
50
  module Architect
@@ -22,13 +52,14 @@ module Space::Architect
22
52
  desc "Scaffold (or top up) the architect project: ARCHITECT.md, space.yaml project block, SessionStart hook"
23
53
  phase 50, "Project"
24
54
  argument :space, required: false, desc: "Space identifier (default: $PWD)"
55
+ commit_message_options
25
56
 
26
- def call(space: nil, **opts)
57
+ def call(space: nil, message: nil, message_from: nil, **opts)
27
58
  setup_terminal(**opts.slice(:color, :colors))
28
59
  handle_errors do
29
60
  render(store.find(space)) do |sp|
30
61
  project = ArchitectProject.new(space: sp)
31
- path = project.init!
62
+ path = project.init!(message: read_commit_message(message: message, message_from: message_from))
32
63
  terminal.say "Project ready: #{terminal.path(path)}"
33
64
  CLI.record_outcome(Outcome.new(exit_code: 0))
34
65
  end
@@ -74,13 +105,15 @@ module Space::Architect
74
105
  phase 10, "Spec"
75
106
  argument :iteration, required: true, desc: "Iteration name (kebab-case)"
76
107
  argument :space, required: false, desc: "Space identifier (default: $PWD)"
108
+ commit_message_options
77
109
 
78
- def call(iteration:, space: nil, **opts)
110
+ def call(iteration:, space: nil, message: nil, message_from: nil, **opts)
79
111
  setup_terminal(**opts.slice(:color, :colors))
80
112
  handle_errors do
81
113
  render(store.find(space)) do |sp|
82
114
  project = ArchitectProject.new(space: sp)
83
- path = project.new_iteration!(iteration)
115
+ path = project.new_iteration!(iteration,
116
+ message: read_commit_message(message: message, message_from: message_from))
84
117
  terminal.say "Iteration scaffolded: #{terminal.path(path)}"
85
118
  CLI.record_outcome(Outcome.new(exit_code: 0))
86
119
  end
@@ -141,19 +174,41 @@ module Space::Architect
141
174
  end
142
175
  end
143
176
 
177
+ class Sync < BaseCommand
178
+ desc "Sync tracked repo clones with their remotes (fast-forward only, no rebase/reset)"
179
+ phase 53, "Project"
180
+ argument :repo, required: false, desc: "Repo name to sync (default: all tracked repos)"
181
+ argument :space, required: false, desc: "Space identifier (default: $PWD)"
182
+
183
+ def call(repo: nil, space: nil, **opts)
184
+ setup_terminal(**opts.slice(:color, :colors))
185
+ handle_errors do
186
+ render(store.find(space)) do |sp|
187
+ project = ArchitectProject.new(space: sp)
188
+ results = project.sync_repos(repo_name: repo)
189
+ results.each { |r| terminal.say r[:message] }
190
+ CLI.record_outcome(Outcome.new(exit_code: 0))
191
+ end
192
+ end
193
+ end
194
+ end
195
+
144
196
  class Freeze < BaseCommand
145
197
  desc "Freeze the iteration's frozen region (Grounds/Specification/Acceptance Criteria) and record the freeze SHA"
146
198
  phase 12, "Spec"
147
199
  argument :iteration, required: true, desc: "Iteration name"
148
200
  argument :space, required: false, desc: "Space identifier (default: $PWD)"
201
+ option :force, type: :boolean, default: false, desc: "Re-freeze even if the frozen region changed (pre-dispatch only)"
202
+ commit_message_options
149
203
 
150
- def call(iteration:, space: nil, **opts)
204
+ def call(iteration:, space: nil, message: nil, message_from: nil, force: false, **opts)
151
205
  setup_terminal(**opts.slice(:color, :colors))
152
206
  handle_errors do
153
207
  render(store.find(space)) do |sp|
154
208
  project = ArchitectProject.new(space: sp)
155
209
  warnings = []
156
- sha = project.freeze!(iteration, warnings: warnings)
210
+ sha = project.freeze!(iteration, warnings: warnings, force: force,
211
+ message: read_commit_message(message: message, message_from: message_from))
157
212
  terminal.say "Frozen #{iteration} at #{sha}"
158
213
  warnings.each { |w| terminal.say "Warning: #{w}" }
159
214
  ac = project.acceptance_criteria(iteration)
@@ -171,15 +226,17 @@ module Space::Architect
171
226
  class Verify < BaseCommand
172
227
  desc "Post-flight mechanical lane checks — frozen-untouched, no builder commits, report exists, in-bounds (reports only, no judgment)"
173
228
  phase 30, "Judge"
174
- argument :iteration, required: true, desc: "Iteration name"
175
- argument :space, required: false, desc: "Space identifier (default: $PWD)"
229
+ argument :iteration, required: true, desc: "Iteration name"
230
+ argument :space, required: false, desc: "Space identifier (default: $PWD)"
231
+ option :commit_mode, default: nil, desc: "Commit mode override (strict|conductor); overrides space.yaml commit_mode for this run"
176
232
 
177
- def call(iteration:, space: nil, **opts)
233
+ def call(iteration:, space: nil, commit_mode: nil, **opts)
178
234
  setup_terminal(**opts.slice(:color, :colors))
179
235
  handle_errors do
180
236
  render(store.find(space)) do |sp|
181
237
  project = ArchitectProject.new(space: sp)
182
- results = project.verify(iteration)
238
+ terminal.say "Effective commit_mode: #{commit_mode}" if commit_mode
239
+ results = project.verify(iteration, commit_mode: commit_mode)
183
240
 
184
241
  if results.empty?
185
242
  terminal.say "No lanes recorded for iteration '#{iteration}'"
@@ -222,6 +279,7 @@ module Space::Architect
222
279
  argument :iteration, required: true, desc: "Iteration name"
223
280
  argument :lane, required: true, desc: "Lane name"
224
281
  argument :space, required: false, desc: "Space identifier (default: $PWD)"
282
+ option :prompt, default: nil, desc: "Read the lane prompt from this file (copied byte-for-byte to build/<id>-<lane>/prompt.md)"
225
283
  option :model, default: nil, desc: "Builder model to pin (default: the lane's model, else the reference default claude-sonnet-4-6). Any provider/tier; pin a full id, not a floating alias"
226
284
  option :max_turns, default: "200", desc: "Max turns for the builder"
227
285
  option :harness, default: nil, desc: "Harness override (claude-code, opencode)"
@@ -232,7 +290,7 @@ module Space::Architect
232
290
  option :push_token, default: nil, desc: "Bearer token for push endpoint authorization"
233
291
  option :push_host, default: nil, desc: "Base URL of the ingest server; the CLI creates a run via POST <host>/runs and streams to /runs/<id>/ingest (requires --push-token)"
234
292
 
235
- def call(iteration:, lane:, space: nil, model: nil,
293
+ def call(iteration:, lane:, space: nil, prompt: nil, model: nil,
236
294
  max_turns: "200", harness: nil, effort: nil, detach: false,
237
295
  timeout: "14400", push_url: nil, push_token: nil, push_host: nil, **opts)
238
296
  setup_terminal(**opts.slice(:color, :colors))
@@ -240,6 +298,7 @@ module Space::Architect
240
298
  render(store.find(space)) do |sp|
241
299
  project = ArchitectProject.new(space: sp)
242
300
  kwargs = { max_turns: max_turns.to_i, detach: detach }
301
+ kwargs[:prompt] = prompt if prompt
243
302
  kwargs[:model] = model if model
244
303
  kwargs[:harness] = harness if harness
245
304
  kwargs[:effort] = effort if effort
@@ -248,6 +307,7 @@ module Space::Architect
248
307
  kwargs[:push_token] = push_token if push_token
249
308
  kwargs[:push_host] = push_host if push_host
250
309
  res = project.dispatch(iteration, lane, **kwargs)
310
+ terminal.say "Prompt: #{prompt} → #{terminal.path(res[:prompt_copied])}" if res[:prompt_copied]
251
311
  if detach
252
312
  terminal.say "PID: #{res[:pid]}"
253
313
  terminal.say "Run log: #{terminal.path(res[:run_log])}"
@@ -278,13 +338,14 @@ module Space::Architect
278
338
  argument :space, required: false, desc: "Space identifier (default: $PWD)"
279
339
  option :base, default: nil, desc: "Base ref override (default: project/<slug> HEAD if it exists, else the repo's default branch)"
280
340
  option :lane, default: nil, desc: "Provision only this lane (default: all declared lanes)"
341
+ option :force, type: :boolean, default: false, desc: "Clear and re-create a stale (unregistered) worktree directory"
281
342
 
282
- def call(iteration:, space: nil, base: nil, lane: nil, **opts)
343
+ def call(iteration:, space: nil, base: nil, lane: nil, force: false, **opts)
283
344
  setup_terminal(**opts.slice(:color, :colors))
284
345
  handle_errors do
285
346
  render(store.find(space)) do |sp|
286
347
  project = ArchitectProject.new(space: sp)
287
- results = project.provision(iteration, base: base, lane: lane)
348
+ results = project.provision(iteration, base: base, lane: lane, force: force)
288
349
  if results.empty?
289
350
  terminal.say "No declared lanes to provision for '#{iteration}'"
290
351
  else
@@ -303,21 +364,25 @@ module Space::Architect
303
364
  desc "Write a section of the iteration file and commit it (one call)"
304
365
  phase 11, "Spec"
305
366
  argument :iteration, required: true, desc: "Iteration name"
306
- argument :section, required: true, desc: "Section: grounds, specification, prompt, verdict"
367
+ argument :section, required: true, desc: "Section: grounds, specification, acceptance-criteria, prompt, verdict"
307
368
  argument :space, required: false, desc: "Space identifier (default: $PWD)"
308
369
  option :from, default: nil, desc: "Read the section body from this file"
309
370
  option :body, default: nil, desc: "Inline section body (one-liners)"
310
371
  option :stdin, type: :boolean, default: false, desc: "Read the section body from stdin"
311
372
  option :append, type: :boolean, default: false, desc: "Append a ### <lane> subsection instead of replacing"
312
373
  option :lane, default: nil, desc: "Lane name for an appended ### subsection"
374
+ option :force, type: :boolean, default: false, desc: "Write a frozen section (pre-dispatch only)"
375
+ commit_message_options
313
376
 
314
- def call(iteration:, section:, space: nil, from: nil, body: nil, stdin: false, append: false, lane: nil, **opts)
377
+ def call(iteration:, section:, space: nil, from: nil, body: nil, stdin: false, append: false, lane: nil,
378
+ message: nil, message_from: nil, force: false, **opts)
315
379
  setup_terminal(**opts.slice(:color, :colors))
316
380
  handle_errors do
317
- content = read_section_body(from: from, body: body, stdin: stdin)
381
+ content = read_body(from: from, body: body, stdin: stdin, what: "section body")
318
382
  render(store.find(space)) do |sp|
319
383
  project = ArchitectProject.new(space: sp)
320
- res = project.write_section!(iteration, section, body: content, append: append, lane: lane)
384
+ res = project.write_section!(iteration, section, body: content, append: append, lane: lane, force: force,
385
+ message: read_commit_message(message: message, message_from: message_from))
321
386
  if res[:committed]
322
387
  terminal.say "Committed #{res[:heading]} → #{res[:sha][0, 8]}"
323
388
  terminal.say res[:diffstat] unless res[:diffstat].empty?
@@ -328,15 +393,6 @@ module Space::Architect
328
393
  end
329
394
  end
330
395
  end
331
-
332
- private
333
-
334
- def read_section_body(from:, body:, stdin:)
335
- return File.read(from) if from
336
- return body if body
337
- return $stdin.read if stdin
338
- raise Space::Core::Error, "provide the section body via --from <file>, --body <text>, or --stdin"
339
- end
340
396
  end
341
397
 
342
398
  class Verdict < BaseCommand
@@ -348,28 +404,22 @@ module Space::Architect
348
404
  option :from, default: nil, desc: "Read the verdict body from this file"
349
405
  option :body, default: nil, desc: "Inline verdict body"
350
406
  option :stdin, type: :boolean, default: false, desc: "Read the verdict body from stdin"
407
+ commit_message_options
351
408
 
352
- def call(iteration:, decision:, space: nil, from: nil, body: nil, stdin: false, **opts)
409
+ def call(iteration:, decision:, space: nil, from: nil, body: nil, stdin: false,
410
+ message: nil, message_from: nil, **opts)
353
411
  setup_terminal(**opts.slice(:color, :colors))
354
412
  handle_errors do
355
- content = read_section_body(from: from, body: body, stdin: stdin)
413
+ content = read_body(from: from, body: body, stdin: stdin, what: "verdict body")
356
414
  render(store.find(space)) do |sp|
357
415
  project = ArchitectProject.new(space: sp)
358
- res = project.record_verdict!(iteration, decision: decision, body: content)
416
+ res = project.record_verdict!(iteration, decision: decision, body: content,
417
+ message: read_commit_message(message: message, message_from: message_from))
359
418
  terminal.say "Verdict '#{res[:decision]}' recorded → #{res[:sha][0, 8]}"
360
419
  CLI.record_outcome(Outcome.new(exit_code: 0))
361
420
  end
362
421
  end
363
422
  end
364
-
365
- private
366
-
367
- def read_section_body(from:, body:, stdin:)
368
- return File.read(from) if from
369
- return body if body
370
- return $stdin.read if stdin
371
- raise Space::Core::Error, "provide the verdict body via --from <file>, --body <text>, or --stdin"
372
- end
373
423
  end
374
424
 
375
425
  class Evidence < BaseCommand
@@ -378,13 +428,15 @@ module Space::Architect
378
428
  argument :iteration, required: true, desc: "Iteration name"
379
429
  argument :space, required: false, desc: "Space identifier (default: $PWD)"
380
430
  option :lane, default: nil, desc: "Lane name (per-lane subsection; omit for a single-lane iteration)"
431
+ commit_message_options
381
432
 
382
- def call(iteration:, space: nil, lane: nil, **opts)
433
+ def call(iteration:, space: nil, lane: nil, message: nil, message_from: nil, **opts)
383
434
  setup_terminal(**opts.slice(:color, :colors))
384
435
  handle_errors do
385
436
  render(store.find(space)) do |sp|
386
437
  project = ArchitectProject.new(space: sp)
387
- res = project.transcribe_evidence!(iteration, lane: lane)
438
+ res = project.transcribe_evidence!(iteration, lane: lane,
439
+ message: read_commit_message(message: message, message_from: message_from))
388
440
  terminal.say "Transcribed #{res[:lines]} lines → #{res[:sha][0, 8]}"
389
441
  terminal.say "Builder STATUS: #{res[:status_line]}" if res[:status_line]
390
442
  terminal.say "Now rule on the builder's PHASE 0 disagreements in the Verdict (a later session)."
@@ -397,17 +449,20 @@ module Space::Architect
397
449
  class Merge < BaseCommand
398
450
  desc "Integrate ONE judged-passing lane (merges --no-ff; runs no gates, makes no verdict)"
399
451
  phase 41, "Land"
400
- argument :iteration, required: true, desc: "Iteration name"
401
- argument :lane, required: true, desc: "Lane name (architect-judged passing)"
402
- argument :space, required: false, desc: "Space identifier (default: $PWD)"
403
- option :message, default: nil, desc: "Commit message for the lane's working-tree changes"
404
-
405
- def call(iteration:, lane:, space: nil, message: nil, **opts)
452
+ argument :iteration, required: true, desc: "Iteration name"
453
+ argument :lane, required: true, desc: "Lane name (architect-judged passing)"
454
+ argument :space, required: false, desc: "Space identifier (default: $PWD)"
455
+ option :into, required: false, desc: "Merge into this branch instead of the slug-derived project/<slug> default"
456
+ option :commit_mode, default: nil, desc: "Commit mode override (strict|conductor); overrides space.yaml commit_mode for this run"
457
+ commit_message_options
458
+
459
+ def call(iteration:, lane:, space: nil, message: nil, message_from: nil, into: nil, commit_mode: nil, **opts)
406
460
  setup_terminal(**opts.slice(:color, :colors))
407
461
  handle_errors do
408
462
  render(store.find(space)) do |sp|
409
463
  project = ArchitectProject.new(space: sp)
410
- r = project.merge_lane!(iteration, lane, message: message)
464
+ r = project.merge_lane!(iteration, lane, into: into, commit_mode: commit_mode,
465
+ message: read_commit_message(message: message, message_from: message_from))
411
466
  terminal.say "Merged #{lane} → #{r[:integration_branch]} (#{r[:merge_sha][0, 8]})"
412
467
  terminal.say r[:diffstat] unless r[:diffstat].empty?
413
468
  terminal.say "Gates NOT run — run `architect gate #{iteration}` against the integration branch."
@@ -420,12 +475,15 @@ module Space::Architect
420
475
  class Integrate < BaseCommand
421
476
  desc "Integrate the architect-supplied set of passing lanes, in order (stops on conflict)"
422
477
  phase 40, "Land"
423
- argument :iteration, required: true, desc: "Iteration name"
424
- argument :space, required: false, desc: "Space identifier (default: $PWD)"
425
- option :lanes, required: false, desc: "Comma-separated passing lane names (you decide the set)"
426
- option :teardown, type: :boolean, default: false, desc: "Remove worktrees + delete lane branches after merge"
427
-
428
- def call(iteration:, space: nil, lanes: nil, teardown: false, **opts)
478
+ argument :iteration, required: true, desc: "Iteration name"
479
+ argument :space, required: false, desc: "Space identifier (default: $PWD)"
480
+ option :lanes, required: false, desc: "Comma-separated passing lane names (you decide the set)"
481
+ option :teardown, type: :boolean, default: false, desc: "Remove worktrees + delete lane branches after merge"
482
+ option :commit_mode, default: nil, desc: "Commit mode override (strict|conductor); overrides space.yaml commit_mode for this run"
483
+ option :into, required: false, desc: "Merge into this branch instead of the slug-derived project/<slug> default"
484
+ commit_message_options
485
+
486
+ def call(iteration:, space: nil, lanes: nil, teardown: false, message: nil, message_from: nil, commit_mode: nil, into: nil, **opts)
429
487
  setup_terminal(**opts.slice(:color, :colors))
430
488
  handle_errors do
431
489
  lane_names = lanes.to_s.split(",").map(&:strip).reject(&:empty?)
@@ -434,7 +492,9 @@ module Space::Architect
434
492
 
435
493
  render(store.find(space)) do |sp|
436
494
  project = ArchitectProject.new(space: sp)
437
- results = project.integrate!(iteration, lanes: lane_names, teardown: teardown)
495
+ results = project.integrate!(iteration, lanes: lane_names, teardown: teardown,
496
+ message: read_commit_message(message: message, message_from: message_from),
497
+ commit_mode: commit_mode, into: into)
438
498
  if lane_names.empty?
439
499
  if results.empty?
440
500
  terminal.say "Nothing to tear down for #{iteration}"
@@ -543,15 +603,16 @@ module Space::Architect
543
603
  option :model, default: nil, desc: "Model (required for opencode)"
544
604
  option :effort, default: nil, desc: "Reasoning effort (opencode only; sets reasoningEffort in the model config)"
545
605
  option :touch, default: nil, desc: "Comma-separated file globs the lane may touch (records its touch_set for in-bounds + merge checks)"
606
+ option :force, type: :boolean, default: false, desc: "Clear and re-create a stale (unregistered) worktree directory"
546
607
 
547
- def call(repo:, iteration:, lane:, base: nil, harness: "claude-code", model: nil, effort: nil, touch: nil, **opts)
608
+ def call(repo:, iteration:, lane:, base: nil, harness: "claude-code", model: nil, effort: nil, touch: nil, force: false, **opts)
548
609
  setup_terminal(**opts.slice(:color, :colors))
549
610
  handle_errors do
550
611
  render(store.find) do |sp|
551
612
  project = ArchitectProject.new(space: sp)
552
613
  touch_set = touch ? touch.split(",").map(&:strip).reject(&:empty?) : nil
553
614
  result = project.worktree_add(repo, iteration, lane, base: base,
554
- harness: harness, model: model, effort: effort, touch: touch_set)
615
+ harness: harness, model: model, effort: effort, touch: touch_set, force: force)
555
616
  terminal.say "Worktree: #{terminal.path(result[:worktree])}"
556
617
  terminal.say "Base SHA: #{result[:base_sha]}"
557
618
  CLI.record_outcome(Outcome.new(exit_code: 0))
@@ -690,17 +751,23 @@ module Space::Architect
690
751
 
691
752
  module Brief
692
753
  class New < BaseCommand
693
- desc "Scaffold the durable project brief (architecture/BRIEF.md)"
754
+ desc "Write the durable project brief (architecture/BRIEF.md) — authored via --from/--stdin, or a placeholder template"
694
755
  argument :space, required: false, desc: "Space identifier (default: $PWD)"
695
756
  option :force, type: :boolean, default: false, desc: "Overwrite an existing BRIEF.md"
757
+ option :from, default: nil, desc: "Read the authored brief body from this file"
758
+ option :stdin, type: :boolean, default: false, desc: "Read the authored brief body from stdin"
759
+ commit_message_options
696
760
 
697
- def call(space: nil, force: false, **opts)
761
+ def call(space: nil, force: false, from: nil, stdin: false, message: nil, message_from: nil, **opts)
698
762
  setup_terminal(**opts.slice(:color, :colors))
699
763
  handle_errors do
764
+ content = (from || stdin) ? read_body(from: from, stdin: stdin, what: "brief body") : nil
700
765
  render(store.find(space)) do |sp|
701
766
  project = ArchitectProject.new(space: sp)
702
- path = project.brief_new!(force: force)
703
- terminal.say "Brief ready: #{terminal.path(path)}"
767
+ path = project.brief_new!(force: force, content: content,
768
+ message: read_commit_message(message: message, message_from: message_from))
769
+ note = content ? "" : " (template — Read it before editing)"
770
+ terminal.say "Brief ready: #{terminal.path(path)}#{note}"
704
771
  CLI.record_outcome(Outcome.new(exit_code: 0))
705
772
  end
706
773
  end
@@ -719,6 +786,7 @@ Space::Architect::CLI::Registry.register "init", Space::Architect::CLI::Archit
719
786
  Space::Architect::CLI::Registry.register "ground", Space::Architect::CLI::Architect::Ground
720
787
  Space::Architect::CLI::Registry.register "new", Space::Architect::CLI::Architect::New
721
788
  Space::Architect::CLI::Registry.register "status", Space::Architect::CLI::Architect::Status
789
+ Space::Architect::CLI::Registry.register "sync", Space::Architect::CLI::Architect::Sync
722
790
  Space::Architect::CLI::Registry.register "freeze", Space::Architect::CLI::Architect::Freeze
723
791
  Space::Architect::CLI::Registry.register "verify", Space::Architect::CLI::Architect::Verify
724
792
  Space::Architect::CLI::Registry.register "provision", Space::Architect::CLI::Architect::Provision
@@ -5,11 +5,12 @@ require_relative "../oci_runner"
5
5
 
6
6
  module Space::Core::CLI
7
7
  class Run < BaseCommand
8
- desc "Run the packed OCI image for the current space (auth injected at runtime)"
8
+ desc "Run the packed OCI image for the current space (auth injected at runtime). Pass a command and its arguments after `--` so they forward as separate argv tokens"
9
9
 
10
- argument :command, type: :array, required: false, desc: "Command to run in the container (default: login shell)"
10
+ argument :command, type: :array, required: false, desc: "Command to run in the container (default: login shell). Use `--` to pass a command with arguments, e.g. `space run -- hermes -z \"hello\"`"
11
11
  option :tty, type: :boolean, default: nil, desc: "Force interactive TTY (default: auto-detect)"
12
12
  option :env, type: :array, desc: "Host env var to forward into the container (repeatable; adds to run.env)"
13
+ example "-- hermes -z \"What is 17 plus 4?\" # `--` forwards the command and its args as separate tokens (a quoted multi-word command arrives as one token and fails in-guest)"
13
14
 
14
15
  def call(command: [], tty: nil, env: [], **opts)
15
16
  setup_terminal(**opts.slice(:color, :colors))
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Space
4
4
  module Core
5
- VERSION = "4.0.0"
5
+ VERSION = "5.1.0"
6
6
  end
7
7
  end
@@ -46,7 +46,20 @@ message, and prints back what changed (SHA + diff stat; `freeze` prints the
46
46
  frozen AC; `evidence` echoes the builder's STATUS line) — so you don't hand-edit
47
47
  the file or run a separate `git add`/`commit`, and you don't run three follow-ups
48
48
  to see what happened. You still author the *content*; the CLI owns the
49
- *persistence*.
49
+ *persistence*. Two disciplines fall out of that split:
50
+
51
+ - **Author in fresh files; canonical paths are CLI-owned.** Write your content
52
+ to a fresh scratch file (timestamp it — `tmp/` in the space) and hand it to
53
+ the command via `--from`/`--prompt`/`--stdin`. Never `Write`/`Edit` a path you
54
+ haven't read this session — the pre-existing file trips your harness's
55
+ read-before-write guard and burns a failed-write → read → rewrite round trip.
56
+ - **Write detailed commit messages.** Every committing command takes
57
+ `-m`/`--message` and `--message-from <file>`: your first line completes the
58
+ subject after a short canonical prefix (`I01 spec: <your subject>`), the rest
59
+ becomes the body. The space's git log is the loop's durable memory — record
60
+ the *why* (rejected alternatives, BRIEF §refs, judgment reasoning) there, not
61
+ just in your head. Prefer `--message-from <fresh file>` for multiline bodies
62
+ (shells mangle quotes).
50
63
 
51
64
  The builder **never** writes this file — the Acceptance Criteria must stay out
52
65
  of its editable blast radius. Each lane builder writes raw evidence to a scratch
@@ -62,7 +75,9 @@ of done) that span iterations. Every iteration's Grounds/Specification/Acceptanc
62
75
  Criteria/Verdict cites it as **BRIEF §N** (e.g. `(BRIEF §3.1)`), the way each gate
63
76
  addresses its intent back to one frozen reference: the Acceptance Criteria table
64
77
  carries a `Brief §` column, the Specification Objective cites it, the Verdict
65
- reads "diff vs BRIEF §1/§3.3 — CONTINUE". Scaffold it with `architect brief new`.
78
+ reads "diff vs BRIEF §1/§3.3 — CONTINUE". Author it in a fresh scratch file and
79
+ persist with `architect brief new --from <file>` (bare `architect brief new`
80
+ drops a placeholder template for a human to fill).
66
81
  The brief is frozen at the project level — edits to a §section are logged
67
82
  decisions in `ARCHITECT.md`, never silent per-iteration drift. Discovery projects
68
83
  that are still finding their shape defer the brief, cite per-iteration Grounds,
@@ -325,10 +340,14 @@ dispatch-in-the-checkout path:
325
340
 
326
341
  Assemble each lane's lane-prompt (the template in `dispatch.md` + this lane's
327
342
  section of the Specification + the frozen Acceptance Criteria) and write it to
328
- `build/<id>-<lane>/prompt.md` (fed to the builder on stdin); record it in the
343
+ a **fresh timestamped scratch file**, `tmp/prompts/<id>-<lane>-<hhmmss>.md`
344
+ never to `build/<id>-<lane>/prompt.md` directly (a pre-existing canonical file
345
+ trips your harness's read-before-write guard). Record it in the
329
346
  iteration file's **Builder Prompt** section — the dispatched-prompt provenance —
330
347
  with `architect section <iteration> prompt --append --lane <lane> --from
331
- build/<id>-<lane>/prompt.md`. Then run `architect dispatch <iteration> <lane>` — it assembles the
348
+ <that scratch file>`. Then run `architect dispatch <iteration> <lane> --prompt
349
+ <that scratch file>` — it copies the prompt to the canonical
350
+ `build/<id>-<lane>/prompt.md` (fed to the builder on stdin), assembles the
332
351
  canonical `claude -p` argv, pins the model, and streams stream-json to
333
352
  `build/<id>-<lane>/run.jsonl`. Launch one dispatch per worktree — each as its
334
353
  **own background Bash tool call** (your harness's `run_in_background`), **not**
@@ -399,7 +418,9 @@ PASS/FAIL/INVALID, the KILL/CONTINUE call.
399
418
  At project end, landing is yours, not the CLI's — the PR body is judgment
400
419
  output, the same class as a Verdict. Per touched repo: write the PR body
401
420
  yourself — from 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
421
+ to a fresh timestamped file `build/land/<repo>-pr-body-<yyyymmdd-hhmm>.md`
422
+ (never rewrite a prior session's body in place — fresh file, fresh Write),
423
+ then present the paste-and-run block to the
403
424
  human: `cd` to the repo checkout, `git push -u origin project/<slug>`, and
404
425
  `gh pr create --base main --head project/<slug> --title … --body-file …` —
405
426
  paths `~`-contracted, the multi-flag command broken with trailing ` \` at flag
@@ -39,22 +39,27 @@ with the pinned `<builder-model>` or the log isn't growing.
39
39
 
40
40
  ## Canonical dispatch — `architect dispatch <iteration> <lane>`
41
41
 
42
- The canonical path is `architect dispatch <iteration> <lane>`. The tool
43
- assembles the canonical `claude -p` argv, pins the builder model (the lane's
42
+ The canonical path is `architect dispatch <iteration> <lane> --prompt <file>`.
43
+ The tool copies your prompt file to `build/<id>-<lane>/prompt.md` (the CLI owns
44
+ that canonical path — you never write it directly), assembles the canonical
45
+ `claude -p` argv, pins the builder model (the lane's
44
46
  configured model or the CLI's reference default; see `docs/DESIGN.md` §4),
45
- reads the lane prompt from `build/<id>-<lane>/prompt.md` on stdin, and streams
47
+ feeds the copied lane prompt to the builder on stdin, and streams
46
48
  `--output-format stream-json --verbose` output to
47
49
  `build/<id>-<lane>/run.jsonl`. Run each lane as its own **background Bash tool
48
50
  call** (`run_in_background`) so your turn doesn't block for the full run (30–60
49
51
  minutes is typical).
50
52
 
51
- Write the lane's prompt to `build/<id>-<lane>/prompt.md` first (never pass a
52
- big prompt as a shell argument — shells mangle quotes), then:
53
+ Author the lane's prompt in a **fresh timestamped scratch file**
54
+ (`tmp/prompts/<id>-<lane>-<hhmmss>.md` never a pre-existing canonical path,
55
+ which trips your harness's read-before-write guard; never a shell argument —
56
+ shells mangle quotes), then hand it to dispatch:
53
57
 
54
58
  ```bash
55
- # single-lane iteration — run from the space root; dispatch runs in the lane's
56
- # provisioned worktree (materialized on demand from the frozen declaration)
57
- architect dispatch <iteration> <lane>
59
+ # single-lane iteration — run from the space root; dispatch copies the prompt to
60
+ # build/<id>-<lane>/prompt.md and runs in the lane's provisioned worktree
61
+ # (materialized on demand from the frozen declaration)
62
+ architect dispatch <iteration> <lane> --prompt tmp/prompts/<id>-<lane>-<hhmmss>.md
58
63
  ```
59
64
 
60
65
  For multi-lane iterations, materialize every declared lane in one shot with
@@ -62,8 +67,8 @@ For multi-lane iterations, materialize every declared lane in one shot with
62
67
 
63
68
  ```bash
64
69
  architect provision <iteration> # all declared lanes: worktree + lane/<id>-<lane> branch
65
- architect dispatch <iteration> lane-a # own background Bash call each
66
- architect dispatch <iteration> lane-b
70
+ architect dispatch <iteration> lane-a --prompt <lane-a scratch file> # own background Bash call each
71
+ architect dispatch <iteration> lane-b --prompt <lane-b scratch file>
67
72
  ```
68
73
 
69
74
  `architect provision` reads the frozen lane declarations from `space.yaml` and,
@@ -92,7 +97,8 @@ survives the full run and reports completion per lane.
92
97
  and as the manual fallback:
93
98
 
94
99
  ```bash
95
- # write prompt to build/<id>-<lane>/prompt.md first, then:
100
+ # dispatch --prompt copies the scratch prompt to build/<id>-<lane>/prompt.md; the
101
+ # manual equivalent is that copy followed by:
96
102
  ( cd build/<id>-<lane>/wt && \
97
103
  claude -p --model <builder-model> \
98
104
  --permission-mode acceptEdits \
@@ -123,13 +129,16 @@ architect integrate <iteration> --lanes <passing-set> # e.g. --lanes lane-a,la
123
129
  architect gate <iteration> # integration smoke (raw output; verdict stays yours)
124
130
  architect integrate <iteration> --lanes <passing-set> --teardown # or remove worktrees + lane branches after
125
131
  # end of project: landing is the architect's, not a CLI command — write the PR
126
- # body to build/land/<repo>-pr-body.md yourself, then present the paste-and-run
127
- # block (cd, git push -u origin project/<slug>, gh pr create) — see SKILL.md §6
132
+ # body to a fresh build/land/<repo>-pr-body-<yyyymmdd-hhmm>.md yourself, then
133
+ # present the paste-and-run block (cd, git push -u origin project/<slug>,
134
+ # gh pr create) — see SKILL.md §6
128
135
  ```
129
136
 
130
137
  `architect integrate` commits each named lane on its branch and merges it
131
138
  `--no-ff` into the repo's stable `project/<slug>` branch (slug of `space.title`,
132
- persistent across all iterations), in order. It **refuses** a lane that left
139
+ persistent across all iterations), in order. Pass `-m`/`--message-from` the
140
+ lane commit lands in the repo's PR history, so say what the lane did and why,
141
+ not just that it integrated. It **refuses** a lane that left
133
142
  builder commits or wrote out-of-bounds (the mechanical post-flight checks), and
134
143
  aborts on a merge conflict. A merge conflict = the lane plan wasn't disjoint = a
135
144
  spec defect: kill the conflicting lane and re-spec; don't hand-resolve builder
@@ -150,8 +159,9 @@ git -C repos/<repo> merge --no-ff lane/<iteration>-<lane>
150
159
  <run the gate commands> # integration smoke after every merge
151
160
  architect worktree remove <iteration> <lane>
152
161
  git -C repos/<repo> branch -d lane/<iteration>-<lane>
153
- # at project end there is no CLI step: the architect writes
154
- # build/land/<repo>-pr-body.md and presents the push + gh pr create block
162
+ # at project end there is no CLI step: the architect writes a fresh
163
+ # build/land/<repo>-pr-body-<yyyymmdd-hhmm>.md and presents the push +
164
+ # gh pr create block
155
165
  ```
156
166
 
157
167
  ### Parallel + fast-follow
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: space-architect
3
3
  version: !ruby/object:Gem::Version
4
- version: 4.0.0
4
+ version: 5.1.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Eric Jacobs
@@ -205,6 +205,20 @@ dependencies:
205
205
  - - "~>"
206
206
  - !ruby/object:Gem::Version
207
207
  version: '6.0'
208
+ - !ruby/object:Gem::Dependency
209
+ name: mutant-minitest
210
+ requirement: !ruby/object:Gem::Requirement
211
+ requirements:
212
+ - - "~>"
213
+ - !ruby/object:Gem::Version
214
+ version: '0.16'
215
+ type: :development
216
+ prerelease: false
217
+ version_requirements: !ruby/object:Gem::Requirement
218
+ requirements:
219
+ - - "~>"
220
+ - !ruby/object:Gem::Version
221
+ version: '0.16'
208
222
  - !ruby/object:Gem::Dependency
209
223
  name: rake
210
224
  requirement: !ruby/object:Gem::Requirement