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