space-architect 9.9.1 → 9.9.2

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: e18412131c462d1eef898a9476569bb4f35fcd19d9bf046257e54c30fff9cdc2
4
- data.tar.gz: '082471667d803a115ac2d386d567f274779d5e2271f71f1319999928c32a2e76'
3
+ metadata.gz: f318cd07ca8f7d21bb494fa477a77b48e9c5166435e08eb68d7a108646d4b2df
4
+ data.tar.gz: d3aec9ba1be0f7671badd5a0dba43ad9a67c337c87dbd06ea3797ee954488679
5
5
  SHA512:
6
- metadata.gz: 30fc456b394831559cba2c12e8beb12452598428ab3adedb5e9fac49eb45ee5787d03391ae4c920d1b1ad3c034b419527c5707a2711ac7f0bd6ccce4ccde1219
7
- data.tar.gz: 55fc52d111ceba86baea82e7e8409bb92da6d7405564b8708de67a2acb7da106c105e022477e869a41c2076322d694f414b7bd13399519088df0b5341b6218d6
6
+ metadata.gz: 4f7334e56966e8de660ece8d1cfea9239f501ff55b478a47166b7ef2bf888bbca588323c36fe4c80a5469f482f2b350ad23bfd90907c77347f5cb481c172333b
7
+ data.tar.gz: de4f8de478015e918f7c1742fb3d0ccf530d2ddd51d44da037c5eba090cae4c81af24446336277de8a0e61e85f4270956224dbdccda302564b745dd65573ab10
data/CHANGELOG.md CHANGED
@@ -5,6 +5,23 @@ All notable changes to this project are documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [9.9.2] - 2026-11-14
9
+
10
+ ### Added
11
+
12
+ - **`architect looplog` reads tart records (I95)** — alongside the pi roots,
13
+ looplog now lists/follows tart's own run records (`TART_SUBAGENTS_HOME`,
14
+ default `~/.tart/subagents`: `status.json` identity with null-tolerant task
15
+ and dir-name id fallback, newest `sessions/**/*.jsonl` as the transcript
16
+ unit, space-cwd scope filter with both sides symlink-expanded, malformed
17
+ entries skipped silently) and session records (`TART_SESSIONS_DIR`, default
18
+ `~/.tart/sessions`: every `*.jsonl` under the space's slug dir, under the
19
+ `(session)` label). Both render through the ONE existing Renderer via a
20
+ drain-level tart→pi record adapter — no forked display code. Known display
21
+ note: the ✓ line's cache-read slot is filled from `cache_write_tokens`
22
+ (uniformly 0 across the real corpus, so rendering is byte-identical); the
23
+ slot correction rides the open `cache_read_tokens` follow-up.
24
+
8
25
  ## [9.9.1] - 2026-10-30
9
26
 
10
27
  ### Changed
@@ -4,19 +4,23 @@ require "open3"
4
4
 
5
5
  module Space
6
6
  module Architect
7
- # Installs the role playbooks (architect-spec/builder/judge/narrator) to
8
- # ~/.pi/agent/agents and removes a stale architect-coordinator.md left by a
7
+ # Installs the role playbooks (architect-spec/builder/judge/narrator) to the
8
+ # provider's agents dir — ~/.pi/agent/agents for pi, ~/.tart/agents for tart
9
+ # — and removes a stale architect-coordinator.md left by a
9
10
  # pre-driver-era install (I10: the coordinator role retired — the architect
10
- # session itself drives the loop). The playbook text is rendered by the
11
- # installed architect-loop extension itself — the extension owns that text
12
- # (it renders the per-space .pi/agents files from the same source), so this
13
- # installer carries no second copy to drift. Routing pins come from the
14
- # global architect.yaml (ENV["SPACE_ARCHITECT_GLOBAL_CONFIG"] overrides the
15
- # path, honored inside the extension): the user-level files are the
16
- # machine-wide fallback for sessions that cannot see a space's .pi dir;
17
- # a space's own .pi/agents files always win inside that space.
11
+ # session itself drives the loop). One render, two dest roots: the playbook
12
+ # text is rendered by the installed architect-loop extension itself — the
13
+ # extension owns that text (it renders the per-space .pi/agents files from
14
+ # the same source), so this installer carries no second copy to drift; tart
15
+ # gets the same render from the same installed pi extension (tart has no
16
+ # extension of its own — its architect surfaces are native). Routing pins
17
+ # come from the global architect.yaml (ENV["SPACE_ARCHITECT_GLOBAL_CONFIG"]
18
+ # overrides the path, honored inside the extension): the user-level files
19
+ # are the machine-wide fallback for sessions that cannot see a space's .pi
20
+ # dir; a space's own .pi/agents files always win inside that space.
18
21
  module AgentInstaller
19
22
  ROLES = %w[spec builder judge narrator].freeze
23
+ PROVIDERS = %w[pi tart].freeze
20
24
 
21
25
  # Renders each role's agent file and prints it between ===FILE===<role>
22
26
  # markers. Dynamic import (node -e runs CJS; import() is legal there) so
@@ -42,7 +46,8 @@ module Space
42
46
  JS
43
47
 
44
48
  class << self
45
- def install(env: ENV, force: false, dry_run: false, dest: nil, extension_dir: nil)
49
+ def install(env: ENV, provider: "pi", force: false, dry_run: false, dest: nil, extension_dir: nil)
50
+ validate_provider!(provider)
46
51
  source = extension_dir ? Pathname.new(extension_dir).join("architect-loop.ts") : locate_extension(env)
47
52
  unless source&.exist?
48
53
  raise Space::Core::Error,
@@ -50,7 +55,7 @@ module Space
50
55
  "(install-agents renders its playbooks from the installed extension)"
51
56
  end
52
57
 
53
- dest ||= default_dest(env)
58
+ dest ||= default_dest(env, provider)
54
59
  rendered = render_via_node(source, env)
55
60
  results = ROLES.map do |role|
56
61
  write_agent(dest.join("architect-#{role}.md"), rendered.fetch(role), force: force, dry_run: dry_run)
@@ -72,8 +77,21 @@ module Space
72
77
  [{ name: stale.basename.to_s, action: dry_run ? :would_remove : :removed, path: stale }]
73
78
  end
74
79
 
75
- def default_dest(env)
76
- Pathname.new(env.fetch("PI_AGENTS_DIR", File.join(pi_agent_dir(env), "agents")))
80
+ # Provider first: validation precedes the extension lookup and any
81
+ # render, so an unknown provider can never produce side effects.
82
+ def validate_provider!(provider)
83
+ return if PROVIDERS.include?(provider.to_s)
84
+
85
+ raise Space::Core::Error, "Unknown provider '#{provider}'. Expected one of: #{PROVIDERS.join(', ')}"
86
+ end
87
+
88
+ def default_dest(env, provider)
89
+ case provider.to_s
90
+ when "tart"
91
+ Pathname.new(Space::Core::XDG.home(env: env)).join(".tart", "agents")
92
+ else
93
+ Pathname.new(env.fetch("PI_AGENTS_DIR", File.join(pi_agent_dir(env), "agents")))
94
+ end
77
95
  end
78
96
 
79
97
  # Same convention as SkillInstaller: PI_CODING_AGENT_DIR or ~/.pi/agent.
@@ -1175,7 +1175,7 @@ module Space::Architect
1175
1175
  class InstallSkills < BaseCommand
1176
1176
  desc "Install bundled skills (architect, architect-discuss, architect-action, architect-status, architect-research) for a harness"
1177
1177
  phase 53, "Project"
1178
- option :provider, default: "pi", desc: "Skill install target (default: pi; validated by the installer)"
1178
+ option :provider, default: "pi", desc: "Skill install target: claude | codex | opencode | pi | tart (default: pi)"
1179
1179
  option :project, type: :boolean, default: false, desc: "Install to CWD instead of global"
1180
1180
  option :force, type: :boolean, default: false, desc: "Overwrite existing skills that differ"
1181
1181
  option :dry_run, type: :boolean, default: false, desc: "Print what would happen without writing files"
@@ -1213,22 +1213,26 @@ module Space::Architect
1213
1213
  end
1214
1214
  end
1215
1215
 
1216
- # Role playbooks to ~/.pi/agent/agents, rendered from the installed
1217
- # architect-loop extension (the single owner of the playbook text) with
1218
- # global-routing pins — the machine-wide fallback for sessions that
1219
- # cannot see a space's .pi dir. Needs install-extensions to have run.
1216
+ # Role playbooks to the provider's agents dir (~/.pi/agent/agents for pi,
1217
+ # ~/.tart/agents for tart), rendered from the installed architect-loop
1218
+ # extension (the single owner of the playbook text) with global-routing
1219
+ # pins — the machine-wide fallback for sessions that cannot see a space's
1220
+ # .pi dir. Needs install-extensions to have run (both providers render
1221
+ # from the same installed pi extension).
1220
1222
  class InstallAgents < BaseCommand
1221
- desc "Install the role playbooks (architect-spec/builder/judge/narrator) to ~/.pi/agent/agents, rendered from the installed architect-loop extension, and remove a stale architect-coordinator.md"
1223
+ desc "Install the role playbooks (architect-spec/builder/judge/narrator) to the provider's agents dir (~/.pi/agent/agents for pi, ~/.tart/agents for tart), rendered from the installed architect-loop extension, and remove a stale architect-coordinator.md"
1222
1224
  phase 53, "Project"
1225
+ option :provider, default: "pi", desc: "Playbook install target: pi | tart (default: pi)"
1223
1226
  option :force, type: :boolean, default: false, desc: "Overwrite existing playbooks that differ"
1224
1227
  option :dry_run, type: :boolean, default: false, desc: "Print what would happen without writing files"
1225
1228
 
1226
- def call(force: false, dry_run: false, **opts)
1229
+ def call(provider: "pi", force: false, dry_run: false, **opts)
1227
1230
  setup_terminal(**opts.slice(:color, :colors))
1228
1231
  handle_errors do
1229
- result = AgentInstaller.install(env: project_config.env, force: force, dry_run: dry_run)
1232
+ result = AgentInstaller.install(env: project_config.env, provider: provider,
1233
+ force: force, dry_run: dry_run)
1230
1234
  verb = dry_run ? "Would install" : "Installed"
1231
- terminal.say "#{verb} role playbooks for pi → #{terminal.path(result[:dest_root])}"
1235
+ terminal.say "#{verb} role playbooks for #{provider} → #{terminal.path(result[:dest_root])}"
1232
1236
  result[:agents].each do |a|
1233
1237
  terminal.say " #{a[:name]}: #{terminal.style_skill_action(a[:action])} (#{terminal.path(a[:path])})"
1234
1238
  end
@@ -1240,32 +1244,59 @@ module Space::Architect
1240
1244
  # The one-shot machine install: skills (pi by default), the
1241
1245
  # architect-loop extension, then the role playbooks — in that order,
1242
1246
  # because install-agents renders from the extension the prior step just
1243
- # installed. Each step aborts loudly on failure; there are no partial
1244
- # silent states to reason about afterwards.
1247
+ # installed. The extension step is pi-only: for tart it is skipped with
1248
+ # one say line (tart's architect surfaces are native; the playbooks
1249
+ # still render from the installed pi extension). Each step aborts loudly
1250
+ # on failure; there are no partial silent states to reason about
1251
+ # afterwards.
1245
1252
  class Install < BaseCommand
1246
- desc "Install everything for this machine: skills (--provider, default pi), the architect-loop extension, then the role playbooks"
1253
+ desc "Install everything for this machine: skills, the architect-loop extension (pi only), then the role playbooks (--provider pi|tart, default pi)"
1247
1254
  phase 53, "Project"
1248
- option :provider, default: "pi", desc: "Skill install target (default: pi; validated by the installer; passed through to install-skills)"
1255
+ option :provider, default: "pi", desc: "Full-stack install target: pi | tart (default: pi; skills-only providers are rejected — use install-skills for those)"
1249
1256
  option :force, type: :boolean, default: false, desc: "Overwrite existing skills/playbooks that differ (passed through)"
1250
1257
 
1251
1258
  def call(provider: "pi", force: false, **opts)
1252
1259
  setup_terminal(**opts.slice(:color, :colors))
1253
1260
  handle_errors do
1261
+ validate_install_provider!(provider)
1262
+
1254
1263
  skills = SkillInstaller.install(provider, project: false, force: force,
1255
1264
  env: project_config.env)
1256
1265
  terminal.say "skills → #{terminal.path(skills[:dest_root])}"
1257
1266
  skills[:skills].each { |s| terminal.say " #{s[:name]}: #{terminal.style_skill_action(s[:action])}" }
1258
1267
 
1259
- extensions = ExtensionInstaller.install(env: project_config.env)
1260
- extensions[:steps].each { |s| terminal.say " #{s[:cmd]} (exit #{s[:exit_code]})" }
1268
+ if provider == "tart"
1269
+ # tart's architect surfaces are native (BRIEF §6) — no pi
1270
+ # extension step, so no extension output at all; the playbooks
1271
+ # below still render from the installed pi extension.
1272
+ terminal.say "extension: skipped (tart's architect surfaces are native — no pi extension step)"
1273
+ else
1274
+ extensions = ExtensionInstaller.install(env: project_config.env)
1275
+ extensions[:steps].each { |s| terminal.say " #{s[:cmd]} (exit #{s[:exit_code]})" }
1276
+ end
1261
1277
 
1262
- agents = AgentInstaller.install(env: project_config.env, force: force)
1278
+ agents = AgentInstaller.install(env: project_config.env, provider: provider, force: force)
1263
1279
  terminal.say "role playbooks → #{terminal.path(agents[:dest_root])}"
1264
1280
  agents[:agents].each { |a| terminal.say " #{a[:name]}: #{terminal.style_skill_action(a[:action])}" }
1265
1281
 
1266
1282
  CLI.record_outcome(Outcome.new(exit_code: 0))
1267
1283
  end
1268
1284
  end
1285
+
1286
+ private
1287
+
1288
+ # The one-shot is the full stack — pi or tart only. Validation runs
1289
+ # before any step, so a rejected provider leaves zero side effects.
1290
+ def validate_install_provider!(provider)
1291
+ return if AgentInstaller::PROVIDERS.include?(provider.to_s)
1292
+
1293
+ if SkillInstaller::PROVIDERS.include?(provider.to_s)
1294
+ raise Space::Core::Error,
1295
+ "#{provider} is a skills-only provider — architect install runs the full stack (skills, extension, playbooks) for pi or tart; for skills only, run: architect install-skills --provider #{provider}"
1296
+ end
1297
+
1298
+ raise Space::Core::Error, "Unknown provider '#{provider}'. architect install supports: pi, tart"
1299
+ end
1269
1300
  end
1270
1301
 
1271
1302
  module Routing
@@ -3,6 +3,7 @@
3
3
  require "json"
4
4
  require "time"
5
5
  require "find"
6
+ require_relative "loop_cost"
6
7
 
7
8
  module Space
8
9
  module Architect
@@ -31,6 +32,16 @@ module Space
31
32
  # the record's own timestamp (HH:MM:SS local) after the run tag; --list
32
33
  # shows last-activity HH:MM beside the age. Thinking text is NOT truncated —
33
34
  # set RUNLOG_THINK to clamp it if a run goes feral.
35
+ #
36
+ # [I95 loop-parity] tart records read alongside the pi roots, same
37
+ # rendering (B-Laws 1–4): the tart subagents home (~/.tart/subagents, run
38
+ # dirs identified by status.json, transcript = the newest
39
+ # <run>/sessions/**/*.jsonl) and the tart sessions root (~/.tart/sessions)
40
+ # narrowed to the space slug — agent-less session files list under the
41
+ # gem's existing "(session)" label (LoopCost::SESSION_AGENT). Both roots
42
+ # carry the gem's own reader-side env overrides (TART_SUBAGENTS_HOME /
43
+ # TART_SESSIONS_DIR, mirroring PI_SESSIONS_DIR's role) — not tart's
44
+ # identity-namespaced spawn-side keys, which belong to tart's spawner.
34
45
  module Looplog
35
46
  DEFAULT_LIMIT = 2000
36
47
  WINDOW_DEFAULT = 900 # seconds: staleness threshold
@@ -95,13 +106,165 @@ module Space
95
106
  # (agents spawned with cwd = the space) and the space's build/ tree (the
96
107
  # loop's headless --session-dir bootstraps root their whole transcript trees
97
108
  # there). Lane worktrees are the only huge subtrees under build/ — pruned.
109
+ # [I95 B-Law 1] the tart roots append additively; the pi roots and their
110
+ # scan law above are byte-stable. Dedup stays by transcript path.
98
111
  def discover(space, env)
99
112
  roots = [File.join(sessions_root(env), session_slug(space.to_s)), File.join(space.to_s, "build")]
100
113
  .select { |root| File.directory?(root) }
101
114
  .flat_map { |root| scan_root(root) }
115
+ roots += scan_tart_runs(tart_subagents_home(env), space.to_s)
116
+ roots += scan_tart_sessions(File.join(tart_sessions_root(env), session_slug(space.to_s)))
102
117
  roots.uniq { |t| t.path }
103
118
  end
104
119
 
120
+ # The gem's reader-side overrides (B-Law 1) — PI_SESSIONS_DIR's role for
121
+ # the tart roots. Deliberately NOT tart's identity-namespaced spawn-side
122
+ # keys: reading another harness's spawn env would couple the reader to
123
+ # its spawner law.
124
+ def tart_subagents_home(env)
125
+ env["TART_SUBAGENTS_HOME"] || File.join(Dir.home, ".tart", "subagents")
126
+ end
127
+
128
+ def tart_sessions_root(env)
129
+ env["TART_SESSIONS_DIR"] || File.join(Dir.home, ".tart", "sessions")
130
+ end
131
+
132
+ # [I95 B-Law 2] the run-record law: each <home>/<run-id>/ dir whose
133
+ # status.json parses is a run; it is IN SCOPE iff its spawn cwd is the
134
+ # space or under it (lane worktrees stay in scope and keep the tag_label
135
+ # lane-tag law via their recorded cwd). The transcript — the unit, pi-side
136
+ # parity — is the NEWEST <run>/sessions/**/*.jsonl; a run with no
137
+ # transcript is not listed. Malformed status.json or missing identity keys
138
+ # skip silently (discovery never raises — the pi-side discipline). The
139
+ # consumed keys are cwd + agent (both Strings across the real corpus; id
140
+ # falls back to the dir name); task may be null in real status.json files
141
+ # (measured: run 01a14df7-7750… carries task:null AND a real transcript),
142
+ # so a null/absent task tolerates to nil rather than erasing the run.
143
+ def scan_tart_runs(home, space)
144
+ return [] unless File.directory?(home)
145
+
146
+ Dir.children(home).filter_map do |entry| # paths:exempt — walks the tart subagents home's run dirs; per-run status.json discovery IS this module's contract (B-Law 2), mirroring scan_root's pi walk
147
+ run_dir = File.join(home, entry)
148
+ next unless File.directory?(run_dir)
149
+
150
+ status = tart_status(run_dir)
151
+ next unless status
152
+ next unless tart_in_space?(status["cwd"], space)
153
+
154
+ transcript, st = tart_newest_transcript(run_dir)
155
+ next unless transcript
156
+
157
+ run_id = status["id"].is_a?(String) ? status["id"] : entry
158
+ Transcript.new(transcript, run_id, status["agent"], st.mtime.to_f, st.size,
159
+ status["cwd"], tart_task_line(status["task"]))
160
+ end
161
+ end
162
+
163
+ # [I95 B-Law 3] the session-record law: every *.jsonl under the tart slug
164
+ # dir (any depth, wt/ pruned as today) is a transcript with the gem's own
165
+ # agent-less label (LoopCost::SESSION_AGENT — no invented labels, no mode
166
+ # detection). Identity rides the line-1 session header (id, cwd); the
167
+ # task is the first user-message entry's first line. Malformed/empty
168
+ # files skip silently.
169
+ def scan_tart_sessions(slug_dir)
170
+ return [] unless File.directory?(slug_dir)
171
+
172
+ Find.find(slug_dir).filter_map do |p| # paths:exempt — any-depth discovery of the tart slug dir IS this module's contract (mirrors scan_root's pi walk)
173
+ if File.directory?(p)
174
+ Find.prune if File.basename(p) == "wt"
175
+ next
176
+ end
177
+ next unless p.end_with?(".jsonl")
178
+
179
+ header = tart_session_header(p)
180
+ next unless header
181
+
182
+ st = File.stat(p)
183
+ Transcript.new(p, header["id"], LoopCost::SESSION_AGENT, st.mtime.to_f, st.size,
184
+ header["cwd"], tart_first_user_task(p))
185
+ end
186
+ end
187
+
188
+ def tart_status(run_dir)
189
+ status = JSON.parse(File.read(File.join(run_dir, "status.json")))
190
+ return unless status.is_a?(Hash) && status["cwd"].is_a?(String) && status["agent"].is_a?(String)
191
+
192
+ status
193
+ rescue JSON::ParserError, SystemCallError
194
+ nil
195
+ end
196
+
197
+ # The run's spawn cwd is the space or under it (B-Law 2) — expand BOTH
198
+ # sides, so a cwd or space spelled through a symlink (macOS /tmp →
199
+ # /private/tmp) still scopes correctly.
200
+ def tart_in_space?(cwd, space)
201
+ expanded = File.expand_path(cwd)
202
+ root = File.expand_path(space)
203
+ expanded == root || expanded.start_with?("#{root}/")
204
+ end
205
+
206
+ def tart_newest_transcript(run_dir)
207
+ Dir.glob(File.join(run_dir, "sessions", "**", "*.jsonl")) # paths:exempt — the NEWEST <run>/sessions/**/*.jsonl IS the transcript unit (B-Law 2); the recursive glob stays inside the run's own session tree
208
+ .filter_map do |p|
209
+ begin
210
+ st = File.stat(p)
211
+ st.file? ? [p, st] : nil
212
+ rescue SystemCallError
213
+ nil
214
+ end
215
+ end
216
+ .max_by { |_, st| st.mtime }
217
+ end
218
+
219
+ def tart_session_header(path)
220
+ line = File.foreach(path).first
221
+ return unless line&.start_with?("{")
222
+
223
+ header = JSON.parse(line)
224
+ return unless header.is_a?(Hash) && header["type"] == "session" &&
225
+ header["id"].is_a?(String) && header["cwd"].is_a?(String)
226
+
227
+ header
228
+ rescue JSON::ParserError, SystemCallError
229
+ nil
230
+ end
231
+
232
+ # Head-bounded like backscan's 64 KB window: the first user message sits
233
+ # at the head of real transcripts, and the bound keeps a feral file from
234
+ # stalling discovery.
235
+ def tart_first_user_task(path)
236
+ read = 0
237
+ File.open(path, "rb") do |f|
238
+ f.each_line do |line|
239
+ return nil if read > 64 * 1024
240
+
241
+ read += line.bytesize
242
+ next unless line.start_with?("{")
243
+
244
+ rec = begin
245
+ JSON.parse(line)
246
+ rescue JSON::ParserError
247
+ next
248
+ end
249
+ msg = rec["message"]
250
+ next unless rec["type"] == "message" && msg.is_a?(Hash) && msg["role"] == "user"
251
+
252
+ return tart_task_line(msg["content"].is_a?(String) ? msg["content"] : nil)
253
+ end
254
+ end
255
+ nil
256
+ rescue SystemCallError
257
+ nil
258
+ end
259
+
260
+ # The pi-side task clip law: first line, chomped, 110 (note_record's clip,
261
+ # applied to tart identities).
262
+ def tart_task_line(text)
263
+ return nil unless text.is_a?(String)
264
+
265
+ text.lines.first.to_s.chomp[0, 110]
266
+ end
267
+
105
268
  def scan_root(root)
106
269
  Find.find(root).filter_map do |p| # paths:exempt — walks the foreign pi sessions corpus and the space's build/ tree (nested session dirs included); any-depth discovery of *_transcript.jsonl IS this module's contract, mirroring the bin/looplog oracle
107
270
  if File.directory?(p)
@@ -159,6 +322,110 @@ module Space
159
322
  [cwd, task]
160
323
  end
161
324
 
325
+ # [I95 B-Law 4] the tart-record adapter: projects one tart session entry
326
+ # onto the pi record vocabulary so the ONE Renderer keeps the display
327
+ # law, the stamps, and the ✓/✗ lines — shared, not forked. Pi records
328
+ # (recordType-carriers) and foreign shapes pass through untouched, so
329
+ # the pi drain path is byte-stable. The tart vocabulary, measured over
330
+ # the real corpus: {type: session|message|custom_message}; message
331
+ # entries carry {role: user|assistant|tool, content: String} with
332
+ # assistant extras tool_calls (a Hash keyed by call id, values
333
+ # {id, name, arguments: Hash}), thinking (String), flat usage keys, and
334
+ # finish_reason ∈ tool_calls|stop|length|error.
335
+ def adapt_record(rec)
336
+ return [rec] unless rec.is_a?(Hash) && rec["recordType"].nil?
337
+
338
+ case rec["type"]
339
+ when "session" then [] # header record: identity, not display
340
+ when "custom_message" then [] # wake/notify echoes: pi's system/custom law
341
+ when "message" then tart_message_records(rec)
342
+ else [rec] # foreign vocabulary: the renderer's else-arm
343
+ end
344
+ end
345
+
346
+ def tart_message_records(rec)
347
+ msg = rec["message"]
348
+ return [] unless msg.is_a?(Hash)
349
+
350
+ ts = tart_ts(rec)
351
+ case msg["role"]
352
+ when "user"
353
+ # The exchange opener: no display of its own, exactly like pi's.
354
+ [{ "recordType" => "message", "role" => "user", "ts" => ts }]
355
+ when "assistant"
356
+ [tart_assistant_record(msg, ts)] + tart_tool_starts(msg, ts)
357
+ when "tool"
358
+ # Tart records carry no error flag — isError false (the ✗ arm stays
359
+ # pi-only; a tart error surfaces through finish_reason instead).
360
+ [{ "recordType" => "message", "role" => "toolResult", "text" => msg["content"].to_s,
361
+ "isError" => false, "ts" => ts }]
362
+ else # legacy/system roles (bashExecution, …): everything of interest shows elsewhere
363
+ []
364
+ end
365
+ end
366
+
367
+ # Block order is the shared display law's order (thinking dim, then the
368
+ # text, then the tool calls) — the real chronology of one assistant turn.
369
+ def tart_assistant_record(msg, ts)
370
+ blocks = []
371
+ thinking = msg["thinking"]
372
+ blocks << { "type" => "thinking", "thinking" => thinking } if thinking.is_a?(String) && !thinking.empty?
373
+ content = msg["content"]
374
+ blocks << { "type" => "text", "text" => content } if content.is_a?(String) && !content.empty?
375
+ tart_tool_calls(msg).each do |call|
376
+ blocks << { "type" => "toolCall", "id" => call["id"], "name" => call["name"],
377
+ "arguments" => call["arguments"] }
378
+ end
379
+ {
380
+ "recordType" => "message", "role" => "assistant", "ts" => ts,
381
+ "stopReason" => tart_stop_reason(msg["finish_reason"]),
382
+ # Usage maps output_tokens→output, cache_write_tokens→the cache slot
383
+ # (B-Law 4); tart's cache_read_tokens is deliberately NOT mapped —
384
+ # no fabricated numbers. Cost is never fabricated.
385
+ "usage" => { "output" => msg["output_tokens"], "cacheRead" => msg["cache_write_tokens"] },
386
+ "message" => { "content" => blocks }
387
+ }
388
+ end
389
+
390
+ # tool_calls is a Hash keyed by call id across the real corpus (597/597);
391
+ # an Array of entries is tolerated defensively, anything else is absence.
392
+ def tart_tool_calls(msg)
393
+ calls = msg["tool_calls"]
394
+ entries = calls.is_a?(Hash) ? calls.each_value : calls.is_a?(Array) ? calls : []
395
+ entries.select { |call| call.is_a?(Hash) && call["name"] }
396
+ end
397
+
398
+ # The harness re-emits canonical args on tool_start in pi — the adapter
399
+ # synthesizes that record here, so the one-lined ⚒ law renders exactly
400
+ # as pi's (arguments are JSON-roundtripped values, so generate is safe).
401
+ def tart_tool_starts(msg, ts)
402
+ tart_tool_calls(msg).map do |call|
403
+ { "recordType" => "tool_start", "toolName" => call["name"].to_s,
404
+ "argsPayload" => JSON.generate(call["arguments"]), "ts" => ts }
405
+ end
406
+ end
407
+
408
+ # finish_reason → pi stopReason vocabulary (B-Law 4): the two shared
409
+ # semantics map, everything else — length, error, nil — passes verbatim
410
+ # ("error" keys the shared ✗ failed arm; "length" still closes with ✓).
411
+ def tart_stop_reason(finish_reason)
412
+ case finish_reason
413
+ when "tool_calls" then "toolUse"
414
+ when "stop" then "end_turn"
415
+ else finish_reason
416
+ end
417
+ end
418
+
419
+ # The entry timestamp (ISO) carries the record stamp law unchanged, as
420
+ # epoch-ms so the shared exchange arithmetic (elapsed) works unchanged.
421
+ def tart_ts(rec)
422
+ return unless rec["timestamp"].is_a?(String)
423
+
424
+ Time.parse(rec["timestamp"]).to_f * 1000
425
+ rescue ArgumentError
426
+ nil
427
+ end
428
+
162
429
  # [architect-builder:67-core] when cwd is a lane worktree — the full
163
430
  # agent name plus the iteration ordinal and the lane name, resolved
164
431
  # against the space's known iteration ids (architecture/I<NN>-<name>.md)
@@ -351,7 +618,7 @@ module Space
351
618
  discover(@space, @env).sort_by { |t| -t.mtime }.each do |t|
352
619
  age_min = (now - t.mtime) / 60
353
620
  active = (now - t.mtime) < @window
354
- cwd, task = backscan(t.path)
621
+ cwd, task = record_meta(t)
355
622
  @io.puts format("%s %-9s %5s %5.0fm %7sK %s %s",
356
623
  active ? green("●") : dim("·"),
357
624
  tag_label(t.agent, cwd, iteration_ids: iteration_ids),
@@ -387,8 +654,14 @@ module Space
387
654
  rescue JSON::ParserError
388
655
  next
389
656
  end
390
- run.stamp = stamp_for(rec)
391
- emit(run, run.renderer.render(rec))
657
+ # [I95 B-Law 4] one tart entry projects to several records (the
658
+ # message plus its synthesized tool starts); all share the entry's
659
+ # stamp, taken from the projected record so the epoch-ms arm of the
660
+ # unchanged stamp law renders tart rows local exactly like pi rows.
661
+ # Pi lines pass through as the single-element array — same bytes.
662
+ projected = adapt_record(rec)
663
+ run.stamp = stamp_for(projected.first || rec)
664
+ projected.each { |pi_rec| emit(run, run.renderer.render(pi_rec)) }
392
665
  end
393
666
  end
394
667
 
@@ -403,8 +676,17 @@ module Space
403
676
  .map { |p| File.basename(p, ".md") }
404
677
  end
405
678
 
406
- def adopt(t)
679
+ # [I95 B-Laws 2–3] tart transcripts carry identity on the record itself
680
+ # (the run's status.json / the session header); pi transcripts are
681
+ # backscanned. A nil tart slot falls through to the backscan, so a
682
+ # headerless tart file still shows a derived cwd.
683
+ def record_meta(t)
407
684
  cwd, task = backscan(t.path)
685
+ [t.cwd || cwd, t.task || task]
686
+ end
687
+
688
+ def adopt(t)
689
+ cwd, task = record_meta(t)
408
690
  tag = paint_tag(tag_label(t.agent, cwd, iteration_ids: iteration_ids), t.run_id)
409
691
  run = Run.new(t.path, t.run_id, t.agent, tag,
410
692
  Renderer.new(limit: @limit, think_limit: @env["RUNLOG_THINK"]&.to_i, color: @color),
@@ -6,7 +6,7 @@ require "pathname"
6
6
  module Space
7
7
  module Architect
8
8
  module SkillInstaller
9
- PROVIDERS = %w[claude codex opencode pi].freeze
9
+ PROVIDERS = %w[claude codex opencode pi tart].freeze
10
10
 
11
11
  class << self
12
12
  def source_root
@@ -26,6 +26,9 @@ module Space
26
26
  when "pi"
27
27
  base = project ? Pathname.new(cwd) : Pathname.new(pi_agent_dir(env: env))
28
28
  base.join("skills")
29
+ when "tart"
30
+ base = project ? Pathname.new(cwd) : Pathname.new(Space::Core::XDG.home(env: env))
31
+ base.join(".tart", "skills")
29
32
  else
30
33
  raise Space::Core::Error, "Unknown provider '#{provider}'. Expected one of: #{PROVIDERS.join(', ')}"
31
34
  end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Space
4
4
  module Architect
5
- VERSION = "9.9.1"
5
+ VERSION = "9.9.2"
6
6
  end
7
7
  end
@@ -79,8 +79,8 @@ exception path — a loop that needs you and can't reach you does not
79
79
  improvise; it parks the facts and terminates. Exception reports
80
80
  (`notes/exception-<ts>.md`) and completion reports (`notes/report-<ts>.md`)
81
81
  are how a dead or finished loop explains itself; narrations
82
- (`notes/narrative-*.md`) are best-effort updates in between. Read them
83
- before resuming anything.
82
+ (`notes/narrative-*.md`) are the rare prose refreshes — verdict-landed,
83
+ exception, plan-complete. Read them before resuming anything.
84
84
 
85
85
  ## Ground rules
86
86
 
@@ -39,7 +39,7 @@ tool result from this session — no recall, no conversation-only claims.
39
39
  - **Loop:** live or not; if live, which plan, which role runs are in
40
40
  flight and their ages; if not, when the last one settled and how it
41
41
  ended (report/exception).
42
- - **Last settled event:** from the newest narrative — one line of fact.
42
+ - **Newest narrative:** what it records — one line of fact.
43
43
  - **Waiting on the human:** any escalation or exception note that parks
44
44
  a decision.
45
45
  - **Next:** what the plan or ARCHITECT.md says comes next.
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: 9.9.1
4
+ version: 9.9.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Eric Jacobs