space-architect 9.9.0 → 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 +4 -4
- data/CHANGELOG.md +28 -0
- data/lib/space_architect/agent_installer.rb +32 -14
- data/lib/space_architect/cli/architect.rb +47 -16
- data/lib/space_architect/looplog.rb +286 -4
- data/lib/space_architect/skill_installer.rb +4 -1
- data/lib/space_architect/version.rb +1 -1
- data/skill/architect-action/SKILL.md +6 -5
- data/skill/architect-discuss/SKILL.md +2 -2
- data/skill/architect-status/SKILL.md +1 -1
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: f318cd07ca8f7d21bb494fa477a77b48e9c5166435e08eb68d7a108646d4b2df
|
|
4
|
+
data.tar.gz: d3aec9ba1be0f7671badd5a0dba43ad9a67c337c87dbd06ea3797ee954488679
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 4f7334e56966e8de660ece8d1cfea9239f501ff55b478a47166b7ef2bf888bbca588323c36fe4c80a5469f482f2b350ad23bfd90907c77347f5cb481c172333b
|
|
7
|
+
data.tar.gz: de4f8de478015e918f7c1742fb3d0ccf530d2ddd51d44da037c5eba090cae4c81af24446336277de8a0e61e85f4270956224dbdccda302564b745dd65573ab10
|
data/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,34 @@ 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
|
+
|
|
25
|
+
## [9.9.1] - 2026-10-30
|
|
26
|
+
|
|
27
|
+
### Changed
|
|
28
|
+
|
|
29
|
+
- **Driver-mode launch prose (matches the pi-extensions entry-compaction fix)** —
|
|
30
|
+
architect-action and DESIGN no longer describe an entry compaction at
|
|
31
|
+
`architect_loop` launch: a compaction aborts the in-flight turn and, with no
|
|
32
|
+
subagent in flight, nothing resumes the session. The driver begins driving in
|
|
33
|
+
the launch turn and first compacts (`architect_compact`) only after its first
|
|
34
|
+
child launch.
|
|
35
|
+
|
|
8
36
|
## [9.9.0] - 2026-10-30
|
|
9
37
|
|
|
10
38
|
### Added
|
|
@@ -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
|
|
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).
|
|
11
|
-
# installed architect-loop extension itself — the
|
|
12
|
-
# (it renders the per-space .pi/agents files from
|
|
13
|
-
# installer carries no second copy to drift
|
|
14
|
-
#
|
|
15
|
-
#
|
|
16
|
-
#
|
|
17
|
-
#
|
|
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
|
-
|
|
76
|
-
|
|
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
|
|
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
|
|
1217
|
-
#
|
|
1218
|
-
#
|
|
1219
|
-
#
|
|
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,
|
|
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
|
|
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.
|
|
1244
|
-
#
|
|
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
|
|
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: "
|
|
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
|
-
|
|
1260
|
-
|
|
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 =
|
|
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
|
-
|
|
391
|
-
|
|
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
|
-
|
|
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
|
|
@@ -89,11 +89,12 @@ human again until the loop needs them.
|
|
|
89
89
|
a user command in the interactive session, and the human has disengaged.
|
|
90
90
|
The launch is yours, and the tool is your equivalent of `/loop`: call
|
|
91
91
|
`architect_loop` with `from` = the plan file path. The tool installs the
|
|
92
|
-
driver playbook into THIS session as operating instructions and
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
92
|
+
driver playbook into THIS session as operating instructions — and driving
|
|
93
|
+
starts in this same turn: the session spawns and tends spec → builders →
|
|
94
|
+
judge itself, one hop from the human. It compacts only after its first
|
|
95
|
+
child launch (`architect_compact`, never before — a compaction with no
|
|
96
|
+
child in flight aborts the turn with no wake source, and the loop stalls).
|
|
97
|
+
An older extension without the driver-mode `architect_loop` is updated via
|
|
97
98
|
`architect install-extensions` — never spawned around.
|
|
98
99
|
7. **Report the launch, then drive.** Tell the human: the plan path and how
|
|
99
100
|
to watch — `architect looplog` (live), `--list` (inventory), `architect
|
|
@@ -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
|
|
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
|
-
- **
|
|
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.
|