space-architect 5.5.1 → 6.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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +110 -0
- data/lib/space_architect/architect_project.rb +534 -57
- data/lib/space_architect/bug_report.rb +18 -7
- data/lib/space_architect/cli/architect.rb +123 -7
- data/lib/space_architect/cli/research.rb +3 -4
- data/lib/space_architect/harness.rb +73 -28
- data/lib/space_architect/session_sync/runner.rb +4 -2
- data/lib/space_architect/skill_installer.rb +3 -3
- data/lib/space_architect/templates/iteration.md.erb +35 -7
- data/lib/space_core/cli/help.rb +2 -0
- data/lib/space_core/commands.rb +32 -1
- data/lib/space_core/paths.rb +35 -0
- data/lib/space_core/space_store.rb +1 -1
- data/lib/space_core/version.rb +1 -1
- data/lib/space_src/cli/sync.rb +23 -10
- data/lib/space_src/cli.rb +1 -0
- data/lib/space_src/cloner.rb +2 -0
- data/lib/space_src/nav.rb +1 -0
- data/lib/space_src/sync/engine.rb +46 -7
- data/lib/space_src/sync/report.rb +18 -0
- data/lib/space_src.rb +1 -0
- data/skill/architect/SKILL.md +96 -14
- data/skill/architect/dispatch.md +27 -12
- metadata +2 -1
|
@@ -9,20 +9,31 @@ module Space
|
|
|
9
9
|
REPO = "jetpks/space-architect"
|
|
10
10
|
|
|
11
11
|
class << self
|
|
12
|
-
def generate(space: nil, env: ENV, cwd: Dir.pwd, now: Time.now)
|
|
12
|
+
def generate(space: nil, env: ENV, cwd: Dir.pwd, now: Time.now, title: nil)
|
|
13
13
|
body_path = resolve_body_path(space, cwd, now)
|
|
14
14
|
FileUtils.mkdir_p(body_path.dirname)
|
|
15
|
-
body = build_body(space)
|
|
15
|
+
body = build_body(space, title)
|
|
16
16
|
body_path.write(body)
|
|
17
17
|
contracted = Space::Core::Paths.contract(body_path, env: env)
|
|
18
|
+
title_flag = blank_title?(title) ? "" : " --title #{quote_title(title)}"
|
|
18
19
|
command = Space::Core::Commands.wrap(
|
|
19
|
-
%(gh issue create -R #{REPO} --
|
|
20
|
+
%(gh issue create -R #{REPO}#{title_flag} --body-file #{contracted})
|
|
20
21
|
)
|
|
21
22
|
{ body_path: body_path, command: command, body: body }
|
|
22
23
|
end
|
|
23
24
|
|
|
24
25
|
private
|
|
25
26
|
|
|
27
|
+
# Double-quote a title for a POSIX shell: quotes preserve spaces
|
|
28
|
+
# literally, only the characters special inside double quotes are escaped.
|
|
29
|
+
def quote_title(title)
|
|
30
|
+
%("#{title.gsub(/["\\$`]/) { |m| "\\#{m}" }}")
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def blank_title?(title)
|
|
34
|
+
title.to_s.strip.empty?
|
|
35
|
+
end
|
|
36
|
+
|
|
26
37
|
def resolve_body_path(space, cwd, now)
|
|
27
38
|
filename = "architect-bug-report-#{now.strftime('%Y%m%d-%H%M%S')}.md"
|
|
28
39
|
if space
|
|
@@ -32,8 +43,10 @@ module Space
|
|
|
32
43
|
end
|
|
33
44
|
end
|
|
34
45
|
|
|
35
|
-
def build_body(space)
|
|
36
|
-
body = +
|
|
46
|
+
def build_body(space, title)
|
|
47
|
+
body = +""
|
|
48
|
+
body << "# #{title}\n\n" unless blank_title?(title)
|
|
49
|
+
body << template_header
|
|
37
50
|
body << diagnostics_section
|
|
38
51
|
body << space_section(space) if space
|
|
39
52
|
body
|
|
@@ -41,8 +54,6 @@ module Space
|
|
|
41
54
|
|
|
42
55
|
def template_header
|
|
43
56
|
<<~MD
|
|
44
|
-
<!-- Title: <one-line summary> -->
|
|
45
|
-
|
|
46
57
|
**Kind:** <!-- process / tooling / both -->
|
|
47
58
|
|
|
48
59
|
## Summary
|
|
@@ -226,23 +226,127 @@ module Space::Architect
|
|
|
226
226
|
end
|
|
227
227
|
end
|
|
228
228
|
|
|
229
|
+
class Rehearse < BaseCommand
|
|
230
|
+
desc "Rehearse the DRAFTED (unfrozen, working-tree) gates against the repo checkout and report RED/GREEN/BROKEN/EMPTY — runs and reports, never judges"
|
|
231
|
+
phase 12, "Spec"
|
|
232
|
+
argument :iteration, required: true, desc: "Iteration name"
|
|
233
|
+
argument :space, required: false, desc: "Space identifier (default: $PWD)"
|
|
234
|
+
# type: :flag (not :boolean): dry-cli renders a :boolean option as
|
|
235
|
+
# "--[no-]record" in --help, which does not contain the literal
|
|
236
|
+
# substring "--record" that a presence-check on the flag would look
|
|
237
|
+
# for; :flag renders unbracketed ("--record") and is presence-only,
|
|
238
|
+
# which is all this needs.
|
|
239
|
+
option :record, type: :flag, default: false, desc: "Emit a paste-able provenance block summarizing the run, shaped to drop into an Acceptance Criteria preamble"
|
|
240
|
+
|
|
241
|
+
def call(iteration:, space: nil, record: false, **opts)
|
|
242
|
+
setup_terminal(**opts.slice(:color, :colors))
|
|
243
|
+
handle_errors do
|
|
244
|
+
render(store.find(space)) do |sp|
|
|
245
|
+
project = ArchitectProject.new(space: sp)
|
|
246
|
+
result = project.rehearse(iteration)
|
|
247
|
+
render_rehearsal(result)
|
|
248
|
+
terminal.say ""
|
|
249
|
+
terminal.say render_record(result) if record
|
|
250
|
+
CLI.record_outcome(Outcome.new(exit_code: 0))
|
|
251
|
+
end
|
|
252
|
+
end
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
private
|
|
256
|
+
|
|
257
|
+
def render_rehearsal(result)
|
|
258
|
+
if result[:empty]
|
|
259
|
+
reason = result[:placeholder] ? "the scaffold placeholder '#{ArchitectProject::AC1_PLACEHOLDER}' with no active gate" : "no gates drafted yet"
|
|
260
|
+
terminal.say "EMPTY — #{reason}. Nothing to rehearse; the pre-freeze look is still stamped."
|
|
261
|
+
else
|
|
262
|
+
terminal.say "Rehearsing #{result[:iteration]} against #{terminal.path(result[:base_dir])} (repo: #{result[:repo]})"
|
|
263
|
+
result[:gates].each { |g| render_gate(g) }
|
|
264
|
+
render_scope_asymmetry(result[:scope_asymmetry])
|
|
265
|
+
end
|
|
266
|
+
terminal.say ""
|
|
267
|
+
terminal.say "Discrimination report only — this runs and reports; it never judges whether these gates are good, bad, or ready."
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
def render_gate(g)
|
|
271
|
+
terminal.say ""
|
|
272
|
+
terminal.say "── #{g[:ac].empty? ? "(gate)" : g[:ac]}: #{g[:cmd]} (exit #{g[:exit_code].inspect}) [#{g[:rehearsal].to_s.upcase}]"
|
|
273
|
+
terminal.say " dir: #{terminal.path(g[:dir])}"
|
|
274
|
+
terminal.say " reason: #{g[:reason]}" unless g[:reason].to_s.empty?
|
|
275
|
+
if g[:rehearsal] == :broken
|
|
276
|
+
terminal.say " BROKEN is advisory, not certain — a correct RED can look broken (e.g. a file the lane " \
|
|
277
|
+
"hasn't written yet). Confirm before treating it as a defect."
|
|
278
|
+
end
|
|
279
|
+
terminal.say g[:stdout].rstrip unless g[:stdout].strip.empty?
|
|
280
|
+
terminal.say g[:stderr].rstrip unless g[:stderr].strip.empty?
|
|
281
|
+
end
|
|
282
|
+
|
|
283
|
+
# I12/AC3-AC4: reports scope asymmetry only — never affects RED/GREEN/
|
|
284
|
+
# BROKEN, rehearse's exit code, or the stamp. A file outside every
|
|
285
|
+
# declared lane's touch set is flagged loudest: it's the one no lane
|
|
286
|
+
# may legally fix. not_analyzable is rendered too, and counted, so a
|
|
287
|
+
# reader can tell "nothing matched" from "nothing was examined".
|
|
288
|
+
def render_scope_asymmetry(report)
|
|
289
|
+
return if report.nil? || (report[:findings].empty? && report[:not_analyzable].empty?)
|
|
290
|
+
|
|
291
|
+
terminal.say ""
|
|
292
|
+
terminal.say "Scope-asymmetry check (grep-family gates only):"
|
|
293
|
+
report[:findings].each do |f|
|
|
294
|
+
terminal.say "── #{f[:id]}: pattern #{f[:pattern].inspect} searched #{f[:paths].join(', ')}"
|
|
295
|
+
if f[:outside_lanes].any?
|
|
296
|
+
terminal.say " OUTSIDE ANY LANE'S TOUCH SET — no lane may legally fix these:"
|
|
297
|
+
f[:outside_lanes].each { |file| terminal.say " #{file}" }
|
|
298
|
+
end
|
|
299
|
+
terminal.say " also matches (within a declared lane's touch set): #{f[:within_lanes].join(', ')}" if f[:within_lanes].any?
|
|
300
|
+
terminal.say " 0 files elsewhere match" if f[:outside_lanes].empty? && f[:within_lanes].empty?
|
|
301
|
+
end
|
|
302
|
+
|
|
303
|
+
return if report[:not_analyzable].empty?
|
|
304
|
+
terminal.say ""
|
|
305
|
+
terminal.say "Not analyzed (#{report[:not_analyzable].size} grep invocation(s)) — counted, not silently skipped:"
|
|
306
|
+
report[:not_analyzable].each { |na| terminal.say " #{na[:id]}: #{na[:reason]}" }
|
|
307
|
+
end
|
|
308
|
+
|
|
309
|
+
def render_record(result)
|
|
310
|
+
return "> Rehearsed #{result[:iteration]} — no gates drafted; nothing to record." if result[:empty]
|
|
311
|
+
|
|
312
|
+
by = result[:gates].group_by { |g| g[:rehearsal] }
|
|
313
|
+
ids = ->(sym) { (by[sym] || []).map { |g| g[:id] }.join(", ") }
|
|
314
|
+
[
|
|
315
|
+
"> **Dry-run at rehearsal time, recorded for transparency.** All #{result[:gates].size} gate command(s) " \
|
|
316
|
+
"were executed as written against `#{result[:repo]}` under `/bin/sh` — the shell `architect gate` uses.",
|
|
317
|
+
">",
|
|
318
|
+
"> - **RED (#{(by[:red] || []).size} — discriminate):** #{ids.call(:red).empty? ? "(none)" : ids.call(:red)}",
|
|
319
|
+
"> - **GREEN (#{(by[:green] || []).size} — regression guard or non-discriminating):** #{ids.call(:green).empty? ? "(none)" : ids.call(:green)}",
|
|
320
|
+
"> - **BROKEN (#{(by[:broken] || []).size} — advisory, confirm each):** #{ids.call(:broken).empty? ? "(none)" : ids.call(:broken)}"
|
|
321
|
+
].join("\n")
|
|
322
|
+
end
|
|
323
|
+
end
|
|
324
|
+
|
|
229
325
|
class Freeze < BaseCommand
|
|
230
326
|
desc "Freeze the iteration's frozen region (Grounds/Specification/Acceptance Criteria) and record the freeze SHA"
|
|
231
|
-
phase
|
|
327
|
+
phase 13, "Spec"
|
|
232
328
|
argument :iteration, required: true, desc: "Iteration name"
|
|
233
329
|
argument :space, required: false, desc: "Space identifier (default: $PWD)"
|
|
234
330
|
option :force, type: :boolean, default: false, desc: "Re-freeze even if the frozen region changed (pre-dispatch only)"
|
|
331
|
+
# Named --skip-rehearse, not --no-rehearse: Ruby's OptionParser (dry-cli's
|
|
332
|
+
# underlying parser, verified against the live dry-cli 1.4.1 in this repo)
|
|
333
|
+
# treats ANY switch literally named --no-<word> as a boolean negation and
|
|
334
|
+
# silently discards its value, regardless of declared type — so a REASON
|
|
335
|
+
# cannot bind to a flag spelled --no-rehearse. --skip-rehearse is the
|
|
336
|
+
# working escape valve; --no-rehearse is the name this design uses for it.
|
|
337
|
+
option :skip_rehearse, default: nil, desc: "Escape valve (design name: --no-rehearse): skip the fresh-rehearsal requirement, recording REASON in space.yaml (bare freeze refuses without a matching `architect rehearse` stamp)"
|
|
235
338
|
commit_message_options
|
|
236
339
|
|
|
237
|
-
def call(iteration:, space: nil, message: nil, message_from: nil, force: false, **opts)
|
|
340
|
+
def call(iteration:, space: nil, message: nil, message_from: nil, force: false, skip_rehearse: nil, **opts)
|
|
238
341
|
setup_terminal(**opts.slice(:color, :colors))
|
|
239
342
|
handle_errors do
|
|
240
343
|
render(store.find(space)) do |sp|
|
|
241
344
|
project = ArchitectProject.new(space: sp)
|
|
242
345
|
warnings = []
|
|
243
|
-
sha = project.freeze!(iteration, warnings: warnings, force: force,
|
|
346
|
+
sha = project.freeze!(iteration, warnings: warnings, force: force, skip_rehearse_reason: skip_rehearse,
|
|
244
347
|
message: read_commit_message(message: message, message_from: message_from))
|
|
245
348
|
terminal.say "Frozen #{iteration} at #{sha}"
|
|
349
|
+
terminal.say "Rehearsal requirement skipped — #{skip_rehearse}" if skip_rehearse
|
|
246
350
|
warnings.each { |w| terminal.say "Warning: #{w}" }
|
|
247
351
|
ac = project.acceptance_criteria(iteration)
|
|
248
352
|
unless ac.to_s.strip.empty?
|
|
@@ -402,6 +506,9 @@ module Space::Architect
|
|
|
402
506
|
terminal.say "Report: #{terminal.path(res[:report])}"
|
|
403
507
|
terminal.say "Ingest URL: #{res[:push_url]}" if res[:push_url]
|
|
404
508
|
terminal.say "Builder exited with status #{res[:exit_code]}"
|
|
509
|
+
unless res[:report].exist? && !res[:report].read.strip.empty?
|
|
510
|
+
terminal.say "WARNING: no report at #{terminal.path(res[:report])} — the lane produced no deliverable."
|
|
511
|
+
end
|
|
405
512
|
CLI.record_outcome(Outcome.new(exit_code: res[:exit_code]))
|
|
406
513
|
end
|
|
407
514
|
end
|
|
@@ -560,9 +667,10 @@ module Space::Architect
|
|
|
560
667
|
option :teardown, type: :boolean, default: false, desc: "Remove worktrees + delete lane branches after merge"
|
|
561
668
|
option :commit_mode, default: nil, desc: "Commit mode override (strict|conductor); overrides space.yaml commit_mode for this run"
|
|
562
669
|
option :into, required: false, desc: "Merge into this branch instead of the slug-derived project/<slug> default"
|
|
670
|
+
option :accept_bounds, default: nil, desc: "Escape valve: override the in-bounds check for these lanes when the frozen touch-set glob is itself the defect, recording REASON in space.yaml (never overrides the no-builder-commits check)"
|
|
563
671
|
commit_message_options
|
|
564
672
|
|
|
565
|
-
def call(iteration:, space: nil, lanes: nil, teardown: false, message: nil, message_from: nil, commit_mode: nil, into: nil, **opts)
|
|
673
|
+
def call(iteration:, space: nil, lanes: nil, teardown: false, message: nil, message_from: nil, commit_mode: nil, into: nil, accept_bounds: nil, **opts)
|
|
566
674
|
setup_terminal(**opts.slice(:color, :colors))
|
|
567
675
|
handle_errors do
|
|
568
676
|
lane_names = lanes.to_s.split(",").map(&:strip).reject(&:empty?)
|
|
@@ -573,7 +681,7 @@ module Space::Architect
|
|
|
573
681
|
project = ArchitectProject.new(space: sp)
|
|
574
682
|
results = project.integrate!(iteration, lanes: lane_names, teardown: teardown,
|
|
575
683
|
message: read_commit_message(message: message, message_from: message_from),
|
|
576
|
-
commit_mode: commit_mode, into: into)
|
|
684
|
+
commit_mode: commit_mode, into: into, accept_bounds_reason: accept_bounds)
|
|
577
685
|
if lane_names.empty?
|
|
578
686
|
if results.empty?
|
|
579
687
|
terminal.say "Nothing to tear down for #{iteration}"
|
|
@@ -585,6 +693,7 @@ module Space::Architect
|
|
|
585
693
|
else
|
|
586
694
|
results.each do |r|
|
|
587
695
|
terminal.say "Merged #{r[:lane]} → #{r[:integration_branch]} (#{r[:merge_sha][0, 8]})"
|
|
696
|
+
terminal.say "In-bounds check overridden for #{r[:lane]}: #{r[:bounds_override_reason]}" if r[:bounds_override_reason]
|
|
588
697
|
end
|
|
589
698
|
terminal.say "Gates NOT run — run gates: `architect gate #{iteration}`"
|
|
590
699
|
end
|
|
@@ -611,6 +720,7 @@ module Space::Architect
|
|
|
611
720
|
marker = r[:status] == :pass ? "PASS" : "FAIL"
|
|
612
721
|
terminal.say ""
|
|
613
722
|
terminal.say "── #{r[:ac].empty? ? "(gate)" : r[:ac]}: #{r[:cmd]} (exit #{r[:exit_code]}) [#{marker}]"
|
|
723
|
+
terminal.say " dir: #{terminal.path(r[:dir])}"
|
|
614
724
|
terminal.say " reason: #{r[:reason]}" if r[:status] == :fail && !r[:reason].to_s.empty?
|
|
615
725
|
terminal.say r[:stdout].rstrip unless r[:stdout].strip.empty?
|
|
616
726
|
terminal.say r[:stderr].rstrip unless r[:stderr].strip.empty?
|
|
@@ -627,15 +737,20 @@ module Space::Architect
|
|
|
627
737
|
class BugReport < BaseCommand
|
|
628
738
|
desc "Generate a prefilled GitHub issue template for filing bugs against space-architect"
|
|
629
739
|
phase 54, "Project"
|
|
740
|
+
option :title, default: nil, desc: "Issue title, written into --title and the body's leading H1 (omit and gh will prompt for one interactively — the body file does not set it)"
|
|
630
741
|
|
|
631
|
-
def call(**opts)
|
|
742
|
+
def call(title: nil, **opts)
|
|
632
743
|
setup_terminal(**opts.slice(:color, :colors))
|
|
633
744
|
handle_errors do
|
|
634
745
|
space = store.find.value_or(nil)
|
|
635
746
|
result = Space::Architect::BugReport.generate(
|
|
636
747
|
space: space,
|
|
637
|
-
env: project_config.env
|
|
748
|
+
env: project_config.env,
|
|
749
|
+
title: title
|
|
638
750
|
)
|
|
751
|
+
unless title
|
|
752
|
+
terminal.say "No --title given — the body file does not set the issue title; re-run as architect bug-report --title \"...\" to set it in the command (or gh will prompt you for one)."
|
|
753
|
+
end
|
|
639
754
|
terminal.say "Fill the placeholders in #{terminal.path(result[:body_path].to_s)}, then run:"
|
|
640
755
|
terminal.say result[:command]
|
|
641
756
|
terminal.say ""
|
|
@@ -1090,6 +1205,7 @@ Space::Architect::CLI::Registry.register "ground", Space::Architect::CLI::Archit
|
|
|
1090
1205
|
Space::Architect::CLI::Registry.register "new", Space::Architect::CLI::Architect::New
|
|
1091
1206
|
Space::Architect::CLI::Registry.register "status", Space::Architect::CLI::Architect::Status
|
|
1092
1207
|
Space::Architect::CLI::Registry.register "sync", Space::Architect::CLI::Architect::Sync
|
|
1208
|
+
Space::Architect::CLI::Registry.register "rehearse", Space::Architect::CLI::Architect::Rehearse
|
|
1093
1209
|
Space::Architect::CLI::Registry.register "freeze", Space::Architect::CLI::Architect::Freeze
|
|
1094
1210
|
Space::Architect::CLI::Registry.register "verify", Space::Architect::CLI::Architect::Verify
|
|
1095
1211
|
Space::Architect::CLI::Registry.register "provision", Space::Architect::CLI::Architect::Provision
|
|
@@ -6,8 +6,8 @@ module Space::Architect
|
|
|
6
6
|
module Research
|
|
7
7
|
class Dispatch < BaseCommand
|
|
8
8
|
desc "Dispatch detached read-only research lanes (one per prompt file)"
|
|
9
|
-
argument :prompts, required: true,
|
|
10
|
-
|
|
9
|
+
argument :prompts, type: :array, required: true,
|
|
10
|
+
desc: "Prompt file(s) to dispatch (space-separated paths)"
|
|
11
11
|
option :model, default: nil, desc: "Researcher model override (default: the reference default claude-sonnet-4-6)"
|
|
12
12
|
option :max_turns, default: "40", desc: "Max turns per researcher"
|
|
13
13
|
|
|
@@ -15,11 +15,10 @@ module Space::Architect
|
|
|
15
15
|
setup_terminal(**opts.slice(:color, :colors))
|
|
16
16
|
handle_errors do
|
|
17
17
|
render(store.find(opts[:space])) do |sp|
|
|
18
|
-
paths = Array(prompts)
|
|
19
18
|
supervisor = Space::Architect::Research::Supervisor.new(space: sp)
|
|
20
19
|
kwargs = { max_turns: max_turns.to_i }
|
|
21
20
|
kwargs[:model] = model if model
|
|
22
|
-
runs = supervisor.dispatch(
|
|
21
|
+
runs = supervisor.dispatch(prompts, **kwargs)
|
|
23
22
|
runs.each do |run|
|
|
24
23
|
terminal.say "dispatched #{run.id} (pid #{run.pid}) → #{terminal.path(run.run_log_path)}"
|
|
25
24
|
end
|
|
@@ -115,10 +115,19 @@ module Space::Architect
|
|
|
115
115
|
|
|
116
116
|
TIMEOUT_EXIT_CODE = 124
|
|
117
117
|
|
|
118
|
-
#
|
|
119
|
-
# event. Injectable via the run(liveness_delay:) kwarg so tests
|
|
118
|
+
# The liveness fiber's total wait budget before it gives up on the run log's
|
|
119
|
+
# stream-json init event. Injectable via the run(liveness_delay:) kwarg so tests
|
|
120
|
+
# need not sleep seconds.
|
|
120
121
|
LIVENESS_DELAY_SECONDS = 5.0
|
|
121
122
|
|
|
123
|
+
# The liveness fiber's actual deadline is liveness_delay * LIVENESS_BUDGET_FACTOR —
|
|
124
|
+
# a healthy child whose first write lands just after one delay window is still
|
|
125
|
+
# alive, not dead, so the budget spans several delay-lengths, not one.
|
|
126
|
+
LIVENESS_BUDGET_FACTOR = 3
|
|
127
|
+
|
|
128
|
+
# How often the liveness fiber re-checks the run log for growth while waiting.
|
|
129
|
+
LIVENESS_POLL_INTERVAL = 0.05
|
|
130
|
+
|
|
122
131
|
def run(prompt_path:, run_log_path:, chdir:, push_url: nil, push_token: nil, push_client: nil, timeout: nil,
|
|
123
132
|
liveness_delay: LIVENESS_DELAY_SECONDS, err: $stderr)
|
|
124
133
|
prompt_path = Pathname.new(prompt_path)
|
|
@@ -148,15 +157,24 @@ module Space::Architect
|
|
|
148
157
|
end
|
|
149
158
|
end
|
|
150
159
|
|
|
151
|
-
# Liveness self-check:
|
|
152
|
-
#
|
|
153
|
-
#
|
|
154
|
-
#
|
|
160
|
+
# Liveness self-check: read the run log's stream-json init event and print ONE
|
|
161
|
+
# line naming the streamed model + true elapsed time. A single point-sample right
|
|
162
|
+
# at liveness_delay would report a healthy child dead if its first write landed a
|
|
163
|
+
# moment later, so this polls (like Research::Mux#wait_for_file) to a deadline of
|
|
164
|
+
# several delay-lengths, emitting as soon as the log holds a parseable init event —
|
|
165
|
+
# a bounded wait, not an unbounded one, and not satisfied by mere non-emptiness (the
|
|
166
|
+
# child's stderr is teed into the same run log, so one early stderr byte must not
|
|
167
|
+
# count). transient: true so it never keeps the reactor alive; best-effort so it
|
|
168
|
+
# never raises into the run path. run_detached gets no such fiber.
|
|
155
169
|
liveness_task = nil
|
|
156
170
|
if liveness_delay && liveness_delay > 0
|
|
157
171
|
liveness_task = Async(transient: true) do
|
|
158
|
-
|
|
159
|
-
|
|
172
|
+
start = Time.now
|
|
173
|
+
deadline = start + (liveness_delay * LIVENESS_BUDGET_FACTOR)
|
|
174
|
+
until init_event_ready?(run_log_path) || Time.now >= deadline
|
|
175
|
+
sleep LIVENESS_POLL_INTERVAL
|
|
176
|
+
end
|
|
177
|
+
emit_liveness(run_log_path, Time.now - start, err)
|
|
160
178
|
end
|
|
161
179
|
end
|
|
162
180
|
|
|
@@ -207,18 +225,60 @@ module Space::Architect
|
|
|
207
225
|
|
|
208
226
|
private
|
|
209
227
|
|
|
228
|
+
# The liveness fiber's wait predicate: ready once the run log holds a parseable
|
|
229
|
+
# stream-json init event — not merely once it holds any bytes, since the child's
|
|
230
|
+
# stderr is teed into the same run log and one early stderr byte must not satisfy it.
|
|
231
|
+
#
|
|
232
|
+
# Scans only the bytes appended since the previous poll (remembering the offset
|
|
233
|
+
# in @liveness_offset) instead of re-parsing the whole log from the top on every
|
|
234
|
+
# 50ms tick — the child that never emits an init event would otherwise cost
|
|
235
|
+
# O(polls x log size) inside a fiber sharing the reactor with the dispatch itself.
|
|
236
|
+
def init_event_ready?(run_log_path)
|
|
237
|
+
return true if @liveness_model
|
|
238
|
+
|
|
239
|
+
@liveness_offset ||= 0
|
|
240
|
+
File.open(run_log_path, "r") do |f|
|
|
241
|
+
f.seek(@liveness_offset)
|
|
242
|
+
f.each_line do |line|
|
|
243
|
+
break unless line.end_with?("\n")
|
|
244
|
+
@liveness_offset = f.pos
|
|
245
|
+
ev = begin
|
|
246
|
+
JSON.parse(line)
|
|
247
|
+
rescue JSON::ParserError
|
|
248
|
+
next
|
|
249
|
+
end
|
|
250
|
+
next unless ev.is_a?(Hash) && ev["type"] == "system" && ev["subtype"] == "init"
|
|
251
|
+
@liveness_model = ev["model"]
|
|
252
|
+
break
|
|
253
|
+
end
|
|
254
|
+
end
|
|
255
|
+
!@liveness_model.nil?
|
|
256
|
+
rescue StandardError
|
|
257
|
+
false
|
|
258
|
+
end
|
|
259
|
+
|
|
210
260
|
# Read the run log's stream-json init event and print exactly one bounded liveness
|
|
211
|
-
# line to err
|
|
212
|
-
|
|
261
|
+
# line to err, naming the true elapsed wall-clock time since dispatch. Best-effort:
|
|
262
|
+
# swallows any read/parse error so it never raises into run.
|
|
263
|
+
#
|
|
264
|
+
# Reuses init_event_ready?'s own incremental scan rather than re-parsing the
|
|
265
|
+
# whole log from the top: when the poll loop already found the init event,
|
|
266
|
+
# @liveness_model is set and this is a no-op re-check; when it ended on the
|
|
267
|
+
# deadline without one, this performs exactly one more forward scan from
|
|
268
|
+
# @liveness_offset, so bytes appended between the last poll and this call are
|
|
269
|
+
# still observed — a scan forward from the cached offset, never a skipped one.
|
|
270
|
+
def emit_liveness(run_log_path, elapsed, err)
|
|
213
271
|
bytes = run_log_path.exist? ? run_log_path.size : 0
|
|
272
|
+
elapsed = elapsed.round(1)
|
|
214
273
|
if bytes.zero?
|
|
215
|
-
err.puts "liveness: WARN no growth — run log still empty #{
|
|
274
|
+
err.puts "liveness: WARN no growth — run log still empty #{elapsed}s after dispatch"
|
|
216
275
|
return
|
|
217
276
|
end
|
|
218
277
|
|
|
219
|
-
|
|
278
|
+
init_event_ready?(run_log_path)
|
|
279
|
+
streamed = @liveness_model
|
|
220
280
|
if streamed.nil?
|
|
221
|
-
err.puts "liveness: WARN model unverified — no stream-json init event after #{
|
|
281
|
+
err.puts "liveness: WARN model unverified — no stream-json init event after #{elapsed}s (run log #{bytes} bytes)"
|
|
222
282
|
elsif streamed == @model
|
|
223
283
|
err.puts "liveness: OK streaming model=#{streamed} (run log growing, #{bytes} bytes)"
|
|
224
284
|
else
|
|
@@ -228,21 +288,6 @@ module Space::Architect
|
|
|
228
288
|
# Best-effort: an internal read/parse failure must never break the run.
|
|
229
289
|
end
|
|
230
290
|
|
|
231
|
-
# The model named by the stream-json init event ({"type":"system","subtype":"init",...}),
|
|
232
|
-
# or nil if no such event has been logged yet.
|
|
233
|
-
def streamed_init_model(run_log_path)
|
|
234
|
-
run_log_path.each_line do |line|
|
|
235
|
-
ev = begin
|
|
236
|
-
JSON.parse(line)
|
|
237
|
-
rescue JSON::ParserError
|
|
238
|
-
next
|
|
239
|
-
end
|
|
240
|
-
next unless ev.is_a?(Hash) && ev["type"] == "system" && ev["subtype"] == "init"
|
|
241
|
-
return ev["model"]
|
|
242
|
-
end
|
|
243
|
-
nil
|
|
244
|
-
end
|
|
245
|
-
|
|
246
291
|
def argv
|
|
247
292
|
[@bin, "-p", "--model", @model, "--output-format", "stream-json", "--verbose"] + builder_args
|
|
248
293
|
end
|
|
@@ -53,8 +53,10 @@ module Space::Architect
|
|
|
53
53
|
end
|
|
54
54
|
|
|
55
55
|
def files
|
|
56
|
-
pi =
|
|
57
|
-
|
|
56
|
+
pi = Space::Core::Paths.content_tree(@pi_root).select { |p| p.end_with?(".jsonl") }
|
|
57
|
+
.map { |p| [p, SessionId.for_pi(p)] }
|
|
58
|
+
claude = Space::Core::Paths.content_tree(@claude_root).select { |p| p.end_with?(".jsonl") }
|
|
59
|
+
.map { |p| [p, SessionId.for_claude(p)] }
|
|
58
60
|
pi + claude
|
|
59
61
|
end
|
|
60
62
|
end
|
|
@@ -46,7 +46,7 @@ module Space
|
|
|
46
46
|
end
|
|
47
47
|
|
|
48
48
|
def source_skills
|
|
49
|
-
source_root.
|
|
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 =
|
|
97
|
-
dest_files =
|
|
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** —
|
|
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
|
|
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.
|
|
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
|
-
|
|
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,
|
|
66
|
-
# cmd:
|
|
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
|
data/lib/space_core/cli/help.rb
CHANGED
|
@@ -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?
|
data/lib/space_core/commands.rb
CHANGED
|
@@ -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
|
|
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
|
data/lib/space_core/paths.rb
CHANGED
|
@@ -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)
|