@zalom/plastic 1.14.1 → 2.0.0-alpha.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.
- package/PLASTIC.md +101 -294
- package/README.md +5 -6
- package/agents/plastic-advisor.md +18 -17
- package/agents/plastic-enforcer.md +57 -69
- package/agents/plastic-executor.md +34 -20
- package/agents/plastic-faux-advisor.md +18 -18
- package/bin/lib/context_budget.rb +453 -0
- package/bin/plastic-bench +78 -0
- package/config_asks.yml +38 -0
- package/hooks/capture +4 -0
- package/hooks/close +5 -0
- package/hooks/hooks.json +9 -49
- package/hooks/record +4 -0
- package/hooks/savepoint +5 -5
- package/package.json +3 -2
- package/scripts/agent-report +8 -9
- package/scripts/append-ledger +208 -0
- package/scripts/codex-hook +78 -90
- package/scripts/dashboard.rb +4 -4
- package/scripts/day-summary +53 -0
- package/scripts/doctor.rb +106 -43
- package/scripts/end-intent +77 -76
- package/scripts/exec-worktree +11 -20
- package/scripts/file-session-intent +61 -0
- package/scripts/hook-capture +242 -0
- package/scripts/hook-close +36 -0
- package/scripts/hook-record +149 -0
- package/scripts/hook-savepoint +45 -0
- package/scripts/hook-session-start +106 -12
- package/scripts/install.rb +8 -6
- package/scripts/lib/agent_models.rb +1 -7
- package/scripts/lib/arm.rb +283 -0
- package/scripts/lib/backfill_intent.rb +316 -0
- package/scripts/lib/bridge.rb +24 -1597
- package/scripts/lib/compact_instructions.rb +56 -0
- package/scripts/lib/day_summary.rb +206 -0
- package/scripts/lib/doctor_core.rb +65 -88
- package/scripts/lib/doctor_session_ledger.rb +106 -0
- package/scripts/lib/exec_worktree.rb +14 -60
- package/scripts/lib/handoff.rb +184 -0
- package/scripts/lib/harness_text.rb +5 -4
- package/scripts/lib/hook_registry.rb +44 -107
- package/scripts/lib/insights.rb +3 -4
- package/scripts/lib/installer_core.rb +104 -40
- package/scripts/lib/lock.rb +6 -26
- package/scripts/lib/outcome_guard.rb +7 -5
- package/scripts/lib/project_validator.rb +30 -1
- package/scripts/lib/roadmap_queue.rb +3 -3
- package/scripts/lib/roadmap_savepoint.rb +1 -1
- package/scripts/lib/rule_catalog.rb +6 -3
- package/scripts/lib/savepoint.rb +320 -0
- package/scripts/lib/scaffold_intent.rb +13 -179
- package/scripts/lib/session_backfill.rb +184 -0
- package/scripts/lib/session_close.rb +104 -0
- package/scripts/lib/session_git.rb +469 -0
- package/scripts/lib/session_ledger.rb +497 -0
- package/scripts/lib/store_discovery.rb +1 -1
- package/scripts/lib/store_provisioning.rb +1 -1
- package/scripts/maintenance-run +7 -7
- package/scripts/new-intent +63 -4
- package/scripts/plastic-lock +94 -41
- package/scripts/promote-session-item +150 -0
- package/scripts/read-config +3 -0
- package/scripts/rebuild-graph +1 -1
- package/scripts/roadmap-next +2 -2
- package/scripts/rollback.rb +48 -3
- package/scripts/scaffold-intent +37 -63
- package/scripts/session-commit +116 -0
- package/scripts/spawn-preamble +5 -5
- package/scripts/update.rb +7 -3
- package/scripts/write-handoff +60 -0
- package/skills/agent-advisor/SKILL.md +3 -3
- package/skills/agent-advisor/references/advisor-protocol.md +20 -20
- package/skills/auto/SKILL.md +218 -317
- package/skills/auto/evals/evals.json +10 -10
- package/skills/auto/references/agent-architecture.md +102 -90
- package/skills/auto/references/agent-report-contract.md +4 -21
- package/skills/auto/references/end-tail.md +47 -53
- package/skills/auto/references/human-report-contract.md +8 -8
- package/skills/conventions/SKILL.md +1 -3
- package/skills/conventions/references/lifecycle-and-savepoints.md +35 -1
- package/skills/conventions/references/locks-and-worktrees.md +47 -55
- package/skills/conventions/references/maintenance-and-revisions.md +12 -12
- package/skills/dashboard/SKILL.md +1 -1
- package/skills/direct/SKILL.md +66 -0
- package/skills/direct/references/request-signals.md +59 -0
- package/skills/doctor/SKILL.md +58 -11
- package/skills/doctor/report.md +3 -1
- package/skills/install/SKILL.md +11 -9
- package/skills/intent-continuing/SKILL.md +132 -89
- package/skills/intent-continuing/references/boarding-matrix.md +34 -0
- package/skills/intent-continuing/references/context-management.md +2 -2
- package/skills/intent-creating/SKILL.md +2 -2
- package/skills/intent-creating/references/lifecycle.md +1 -1
- package/skills/intent-ending/SKILL.md +28 -37
- package/skills/intent-ending/evals/evals.json +4 -4
- package/skills/intent-executing/SKILL.md +12 -49
- package/skills/intent-executing/plan-reviewer-prompt.md +39 -0
- package/skills/intent-speccing/SKILL.md +108 -51
- package/skills/intent-speccing/evals/evals.json +5 -5
- package/skills/intent-speccing/references/per-section-fill-rules.md +4 -4
- package/skills/intent-speccing/references/self-verify-checklist.md +1 -2
- package/skills/project-creating/SKILL.md +1 -1
- package/skills/releasing/SKILL.md +7 -8
- package/skills/tutorial/SKILL.md +2 -2
- package/skills/tutorial/references/track-1-guided.md +16 -18
- package/skills/tutorial/references/track-2-auto.md +12 -13
- package/skills/tutorial/references/track-3-projects-and-roadmaps.md +2 -2
- package/templates/agents.md +4 -7
- package/templates/config.yml +5 -0
- package/templates/project.yml +7 -0
- package/templates/roadmap.md +1 -1
- package/templates/savepoint.md +2 -2
- package/templates/session-intent.md +29 -0
- package/agents/plastic-brainstorming.md +0 -48
- package/agents/plastic-future-intent-researcher.md +0 -35
- package/agents/plastic-intent-curator.md +0 -47
- package/agents/plastic-intent-discovery.md +0 -43
- package/agents/plastic-planner.md +0 -56
- package/agents/plastic-spec-specialist.md +0 -53
- package/hooks/auto-arm +0 -5
- package/hooks/bash-gate +0 -3
- package/hooks/continue +0 -31
- package/hooks/edit-gates +0 -3
- package/hooks/future-intent-check +0 -25
- package/hooks/gate-check +0 -12
- package/hooks/power-tools +0 -8
- package/scripts/hook-auto-arm +0 -51
- package/scripts/hook-bash-gate +0 -78
- package/scripts/hook-code-gate +0 -38
- package/scripts/hook-continue +0 -48
- package/scripts/hook-create-gate +0 -51
- package/scripts/hook-edit-gates +0 -58
- package/scripts/hook-future-intent-check +0 -90
- package/scripts/hook-gate-check +0 -169
- package/scripts/hook-links-gate +0 -54
- package/scripts/hook-lock-gate +0 -47
- package/scripts/hook-power-tools +0 -38
- package/scripts/hook-savepoint-pre +0 -32
- package/scripts/lib/codex_edit_gates.rb +0 -138
- package/scripts/lib/edit_gates.rb +0 -398
- package/scripts/lib/links_gate.rb +0 -140
- package/scripts/lib/qmd_hook.rb +0 -41
- package/scripts/lib/spec_header.rb +0 -83
- package/scripts/lib/start_intent.rb +0 -296
- package/scripts/start-intent +0 -89
- package/skills/_active-intent-gate.md +0 -26
- package/skills/auto/references/tiers.md +0 -100
- package/skills/continuing/SKILL.md +0 -34
- package/skills/continuing/evals/evals.json +0 -91
- package/skills/conventions/references/gates-and-enforcement.md +0 -53
- package/skills/conventions/references/tiers-and-dispatch.md +0 -135
- package/skills/doctor/references/gates-stuck-detection.md +0 -43
- package/skills/intent-brainstorming/SKILL.md +0 -118
- package/skills/intent-brainstorming/evals/evals.json +0 -67
- package/skills/intent-continuing/evals/evals.json +0 -145
- package/skills/intent-discovering/SKILL.md +0 -56
- package/skills/intent-grilling/SKILL.md +0 -108
- package/skills/intent-linking/SKILL.md +0 -128
- package/skills/intent-linking/evals/evals.json +0 -22
- package/skills/intent-linking/references/zettelkasten.md +0 -45
- package/skills/intent-locking/SKILL.md +0 -64
- package/skills/intent-planning/SKILL.md +0 -145
- package/skills/intent-planning/evals/evals.json +0 -81
- package/skills/intent-planning/references/plan-format.md +0 -130
- package/skills/intent-researching/SKILL.md +0 -123
- package/skills/intent-researching/evals/evals.json +0 -22
- package/skills/intent-savepoint/SKILL.md +0 -83
- package/skills/intent-savepoint/references/context-management.md +0 -32
- package/skills/intent-starting/SKILL.md +0 -151
- package/skills/intent-starting/evals/evals.json +0 -117
- package/skills/intent-starting/references/boarding-matrix.md +0 -35
- package/skills/project-continuing/SKILL.md +0 -119
- package/skills/project-continuing/evals/evals.json +0 -100
- package/skills/roadmap-continuing/SKILL.md +0 -89
- package/skills/roadmap-continuing/evals/evals.json +0 -82
- package/skills/skill-creating/SKILL.md +0 -75
- package/skills/skill-creating/evals/evals.json +0 -108
- package/skills/skill-creating/references/agents.md +0 -168
- package/skills/skill-creating/references/defaults-first.md +0 -23
- package/skills/skill-creating/references/evals.md +0 -41
- package/skills/skill-creating/references/hooks.md +0 -251
- package/skills/skill-creating/references/progressive-disclosure.md +0 -176
- package/skills/skill-creating/references/scripts.md +0 -166
- package/skills/skill-creating/references/skills.md +0 -169
- package/skills/skill-creating/scripts/scaffold.rb +0 -313
- package/skills/skill-evaluating/SKILL.md +0 -141
- package/skills/skill-evaluating/assets/eval-template.json +0 -12
- package/skills/skill-evaluating/evals/evals.json +0 -75
- package/skills/skill-evaluating/references/convention-checks.md +0 -76
- package/skills/skill-evaluating/references/eval-methodology.md +0 -154
- package/skills/store-curating/SKILL.md +0 -64
- package/skills/store-curating/evals/evals.json +0 -38
- package/skills/store-indexing/SKILL.md +0 -93
- package/skills/store-indexing/evals/evals.json +0 -22
- package/skills/store-indexing/references/zettelkasten-linking.md +0 -32
- package/skills/store-provisioning/SKILL.md +0 -55
- /package/skills/{project-continuing → intent-continuing}/references/board-fill.md +0 -0
- /package/skills/{roadmap-continuing → intent-continuing}/references/liveness-ranking.md +0 -0
- /package/skills/{intent-brainstorming → intent-speccing}/references/design-principles.md +0 -0
|
@@ -6,10 +6,14 @@
|
|
|
6
6
|
require "json"
|
|
7
7
|
require "date"
|
|
8
8
|
require "yaml"
|
|
9
|
+
require "fileutils"
|
|
9
10
|
require_relative "lib/bridge"
|
|
11
|
+
require_relative "lib/savepoint"
|
|
10
12
|
require_relative "lib/boot_banner"
|
|
11
13
|
require_relative "lib/qmd_sync"
|
|
12
14
|
require_relative "lib/doctor_core"
|
|
15
|
+
require_relative "lib/session_ledger"
|
|
16
|
+
require_relative "lib/day_summary"
|
|
13
17
|
|
|
14
18
|
index_path, plastic_home, mode, plugin_root = ARGV
|
|
15
19
|
exit 0 unless index_path && plastic_home && mode
|
|
@@ -48,16 +52,20 @@ lines.each do |line|
|
|
|
48
52
|
end
|
|
49
53
|
end
|
|
50
54
|
|
|
51
|
-
# ---
|
|
55
|
+
# --- Stage of the single active intent (the /tmp bridge this once derived was removed in 2.0, intent 307) ---
|
|
52
56
|
|
|
53
|
-
|
|
57
|
+
stage_line = nil
|
|
54
58
|
if active.length == 1 && active.first =~ /store\/([\w-]+)\//
|
|
55
59
|
dir_name = $1
|
|
56
60
|
intent_dir = "#{store_dir}/#{dir_name}"
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
+
begin
|
|
62
|
+
stage = Savepoint.derive_stage(intent_dir)
|
|
63
|
+
missing = Savepoint.missing_for_stage(stage, intent_dir)
|
|
64
|
+
missing_str = missing.empty? ? "none" : missing.join(", ")
|
|
65
|
+
stage_line = "Stage: #{stage} | Next: #{missing_str}"
|
|
66
|
+
rescue StandardError
|
|
67
|
+
stage_line = nil
|
|
68
|
+
end
|
|
61
69
|
end
|
|
62
70
|
|
|
63
71
|
# --- Detect stale future intents ---
|
|
@@ -251,12 +259,7 @@ if plastic_md
|
|
|
251
259
|
if all_active.any?
|
|
252
260
|
parts << "Active intents:\n"
|
|
253
261
|
all_active.each { |a| parts << a }
|
|
254
|
-
if
|
|
255
|
-
stage = bridge_data["build"]["stage"]
|
|
256
|
-
missing = bridge_data["build"]["missing"]
|
|
257
|
-
missing_str = missing.empty? ? "none" : missing.join(", ")
|
|
258
|
-
parts << "Stage: #{stage} | Next: #{missing_str}"
|
|
259
|
-
end
|
|
262
|
+
parts << stage_line if stage_line
|
|
260
263
|
parts << ""
|
|
261
264
|
else
|
|
262
265
|
parts << "No active intents. #{future.length} future intents available.\n"
|
|
@@ -302,6 +305,97 @@ if update_notice
|
|
|
302
305
|
parts.unshift("! #{update_notice}\n")
|
|
303
306
|
end
|
|
304
307
|
|
|
308
|
+
# --- First-boot sweep (intent 301, spec D9): file every unclosed prior day
|
|
309
|
+
# ledger into today, oldest first, at most 3 per boot within a 5-second
|
|
310
|
+
# budget. Each day runs the sibling file-session-intent in its own rescue;
|
|
311
|
+
# the boot never blocks on it.
|
|
312
|
+
begin
|
|
313
|
+
require "open3"
|
|
314
|
+
require "timeout"
|
|
315
|
+
require_relative "lib/session_backfill"
|
|
316
|
+
|
|
317
|
+
sweep_today = SessionLedger.day_id
|
|
318
|
+
sweep_root = SessionLedger.sessions_root(store_dir)
|
|
319
|
+
sweep_filer = File.expand_path("file-session-intent", __dir__)
|
|
320
|
+
sweep_templates = File.expand_path("../templates", __dir__)
|
|
321
|
+
candidates = []
|
|
322
|
+
if Dir.exist?(sweep_root) && File.exist?(sweep_filer)
|
|
323
|
+
candidates = Dir.children(sweep_root).select do |name|
|
|
324
|
+
File.directory?(File.join(sweep_root, name)) && SessionLedger.valid_day_id?(name) &&
|
|
325
|
+
name < sweep_today && !SessionBackfill.closed?(store_dir, name)
|
|
326
|
+
end.sort
|
|
327
|
+
end
|
|
328
|
+
filed = 0
|
|
329
|
+
budget_end = Process.clock_gettime(Process::CLOCK_MONOTONIC) + 5
|
|
330
|
+
candidates.first(3).each do |day|
|
|
331
|
+
break if Process.clock_gettime(Process::CLOCK_MONOTONIC) > budget_end
|
|
332
|
+
|
|
333
|
+
begin
|
|
334
|
+
Timeout.timeout(5) do
|
|
335
|
+
_out, _err, sweep_status = Open3.capture3({ "RUBYOPT" => nil }, RbConfig.ruby, sweep_filer,
|
|
336
|
+
"--day", day, "--carry-to", sweep_today,
|
|
337
|
+
"--store", store_dir, "--templates", sweep_templates)
|
|
338
|
+
filed += 1 if sweep_status.success? && SessionBackfill.closed?(store_dir, day)
|
|
339
|
+
end
|
|
340
|
+
rescue StandardError
|
|
341
|
+
nil
|
|
342
|
+
end
|
|
343
|
+
end
|
|
344
|
+
if filed.positive?
|
|
345
|
+
line = "PLASTIC: filed #{filed} prior day ledger(s) into #{sweep_today}"
|
|
346
|
+
remaining = candidates.size - filed
|
|
347
|
+
line += ", #{remaining} more wait for the next boot" if remaining.positive?
|
|
348
|
+
parts << line
|
|
349
|
+
end
|
|
350
|
+
rescue StandardError
|
|
351
|
+
nil
|
|
352
|
+
end
|
|
353
|
+
|
|
354
|
+
# --- Session day ledger: open or join today, write the per-session pointer
|
|
355
|
+
# and heartbeat (intent 298, spec D4). Best-effort: any failure here degrades
|
|
356
|
+
# to no ledger context line, never blocks the session.
|
|
357
|
+
begin
|
|
358
|
+
templates = File.expand_path("../templates", __dir__)
|
|
359
|
+
day = SessionLedger.day_id
|
|
360
|
+
author = `"#{read_config}" author`.strip
|
|
361
|
+
author = "session" if author.empty?
|
|
362
|
+
SessionLedger.open_day(store: store_dir, day: day, templates: templates, author: author)
|
|
363
|
+
|
|
364
|
+
session = ENV["CLAUDE_CODE_SESSION_ID"] || Process.pid.to_s
|
|
365
|
+
sid = SessionLedger.short_session_id(nil, session)
|
|
366
|
+
SessionLedger.ensure_tmp_root(store_dir)
|
|
367
|
+
FileUtils.mkdir_p(SessionLedger.session_tmp_dir(store_dir, sid))
|
|
368
|
+
File.write(SessionLedger.heartbeat_path(store_dir, sid), "#{Time.now.utc.iso8601}\n")
|
|
369
|
+
pointer = SessionLedger.pointer_path(store_dir, sid)
|
|
370
|
+
File.write(pointer, "#{day}\n") unless File.exist?(pointer)
|
|
371
|
+
|
|
372
|
+
checklist = SessionLedger.checklist_path(store_dir, day)
|
|
373
|
+
open_count = 0
|
|
374
|
+
pending_count = 0
|
|
375
|
+
if File.exist?(checklist)
|
|
376
|
+
File.readlines(checklist).each do |line|
|
|
377
|
+
parsed = SessionLedger.parse_checklist_line(line)
|
|
378
|
+
next unless parsed
|
|
379
|
+
|
|
380
|
+
open_count += 1 if parsed[:state] == :open
|
|
381
|
+
pending_count += 1 if parsed[:state] == :pending
|
|
382
|
+
end
|
|
383
|
+
end
|
|
384
|
+
parts << "PLASTIC: day ledger #{day} joined (#{open_count} open items, #{pending_count} pending)"
|
|
385
|
+
|
|
386
|
+
# The day summary (intent 311, spec D8): open items, the last five done,
|
|
387
|
+
# live auto intents, other active sessions. Never the raw ledger. A
|
|
388
|
+
# failure here leaves the joined line alone.
|
|
389
|
+
begin
|
|
390
|
+
summary = DaySummary.build(store: store_dir, day: day, session: sid, home: plastic_home, now: Time.now)
|
|
391
|
+
parts << summary unless summary.empty?
|
|
392
|
+
rescue StandardError
|
|
393
|
+
nil
|
|
394
|
+
end
|
|
395
|
+
rescue StandardError
|
|
396
|
+
nil
|
|
397
|
+
end
|
|
398
|
+
|
|
305
399
|
# Emit nothing when there is genuinely nothing to surface (no conventions,
|
|
306
400
|
# no deprecations, no update notice).
|
|
307
401
|
exit 0 if parts.join.strip.empty?
|
package/scripts/install.rb
CHANGED
|
@@ -3,7 +3,11 @@
|
|
|
3
3
|
# frozen_string_literal: true
|
|
4
4
|
|
|
5
5
|
# Plastic — `install` verb. Runs via `npx @zalom/plastic install` (bin/plastic.js) or directly.
|
|
6
|
-
# Usage: ruby scripts/install.rb [--claude|--codex|--hermes|--all] [--
|
|
6
|
+
# Usage: ruby scripts/install.rb [--claude|--codex|--hermes|--all] [--reinstall] [--force] [--help]
|
|
7
|
+
#
|
|
8
|
+
# The package version selects the channel: `npx @zalom/plastic@alpha install --claude` (or
|
|
9
|
+
# a pinned `@2.0.0-alpha.1`). The channel flags this verb once listed never selected a
|
|
10
|
+
# package and were removed in 2.0 (intent 310).
|
|
7
11
|
#
|
|
8
12
|
# One-shot by design:
|
|
9
13
|
# - no install present -> fresh install + bootstrap store
|
|
@@ -206,7 +210,7 @@ class Install < InstallerCore
|
|
|
206
210
|
return if codex_like.none? { |a| installed.any? { |r| r[:agent] == a[:name] } }
|
|
207
211
|
|
|
208
212
|
puts " Codex: open Codex, run /hooks, and trust the Plastic hook definitions."
|
|
209
|
-
puts " Plastic's
|
|
213
|
+
puts " Plastic's hooks will not fire until you do.\n\n"
|
|
210
214
|
end
|
|
211
215
|
|
|
212
216
|
def show_help
|
|
@@ -223,10 +227,8 @@ class Install < InstallerCore
|
|
|
223
227
|
--hermes Install for Hermes
|
|
224
228
|
--all Install for all supported agents
|
|
225
229
|
|
|
226
|
-
Channel
|
|
227
|
-
|
|
228
|
-
--beta Beta channel
|
|
229
|
-
--alpha Alpha channel
|
|
230
|
+
Channel: pin the package, e.g. `npx @zalom/plastic@alpha install --claude`
|
|
231
|
+
(@latest is stable and the default; @beta and @alpha are the other channels)
|
|
230
232
|
|
|
231
233
|
Other options:
|
|
232
234
|
--reinstall Re-sync core files for the installed version (repair). Store untouched.
|
|
@@ -12,13 +12,7 @@ module AgentModels
|
|
|
12
12
|
# file basenames without the `.md` extension.
|
|
13
13
|
TIER_DEFAULTS = {
|
|
14
14
|
"plastic-enforcer" => "opus",
|
|
15
|
-
"plastic-
|
|
16
|
-
"plastic-planner" => "opus",
|
|
17
|
-
"plastic-spec-specialist" => "sonnet",
|
|
18
|
-
"plastic-executor" => "sonnet",
|
|
19
|
-
"plastic-intent-curator" => "sonnet",
|
|
20
|
-
"plastic-future-intent-researcher" => "sonnet",
|
|
21
|
-
"plastic-intent-discovery" => "sonnet"
|
|
15
|
+
"plastic-executor" => "sonnet"
|
|
22
16
|
}.freeze
|
|
23
17
|
|
|
24
18
|
# The two advisor agents (intent 185 final design): plastic-advisor (the real
|
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
# encoding: UTF-8
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require "digest"
|
|
5
|
+
require "fileutils"
|
|
6
|
+
require_relative "lock"
|
|
7
|
+
require_relative "worktree"
|
|
8
|
+
require_relative "session_ledger"
|
|
9
|
+
require_relative "savepoint"
|
|
10
|
+
|
|
11
|
+
# Arm - how an auto team takes an intent and gives it back (intent 307).
|
|
12
|
+
#
|
|
13
|
+
# Three durable facts make a delivery: the delivery lock in the intent
|
|
14
|
+
# directory (who owns it), the code worktree under the project repo (where
|
|
15
|
+
# the code lands), and the per-session pointer in the global store's `.tmp/`
|
|
16
|
+
# (which intent this session records into; a day id means the day ledger).
|
|
17
|
+
# Before 2.0 a fourth thing, a `/tmp` bridge JSON, cached all three plus a
|
|
18
|
+
# stage snapshot; ruling 6 of intent 296 retired it, and this module is what
|
|
19
|
+
# replaced the bridge's arm, disarm, and repair methods (removed in 2.0, intent 307).
|
|
20
|
+
#
|
|
21
|
+
# Pure and dependency-injected: every path and clock is an argument, every git
|
|
22
|
+
# call goes through an injected runner, and the only environment read is the
|
|
23
|
+
# `env:` value the caller passes to `resolve_session`. Nothing here raises for
|
|
24
|
+
# a lock outcome; callers get a status and decide.
|
|
25
|
+
module Arm
|
|
26
|
+
module_function
|
|
27
|
+
|
|
28
|
+
STATUSES = %i[acquired owned held stale excluded corrupt].freeze
|
|
29
|
+
|
|
30
|
+
def blank?(value)
|
|
31
|
+
value.nil? || value.to_s.strip.empty?
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Deterministic, session-less key derived from the store and the intent id,
|
|
35
|
+
# for headless runs that carry no session id at all (moved from Bridge).
|
|
36
|
+
def derive_key(store, intent_id)
|
|
37
|
+
"auto-" + Digest::SHA256.hexdigest("#{store}/#{intent_id}")[0, 10]
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# The session that keys a lock: the explicit id, else the env id the caller
|
|
41
|
+
# read (CLAUDE_CODE_SESSION_ID), else the derived key. Never nil.
|
|
42
|
+
def resolve_session(explicit, env: nil, store:, intent_id:)
|
|
43
|
+
return explicit.to_s.strip unless blank?(explicit)
|
|
44
|
+
return env.to_s.strip unless blank?(env)
|
|
45
|
+
derive_key(store, intent_id)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# --- derivations (no I/O beyond Dir.exist?) ---------------------------------
|
|
49
|
+
|
|
50
|
+
def intent_id_for(intent_dir)
|
|
51
|
+
File.basename(intent_dir.to_s).split("--", 2).first
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def store_for(intent_dir)
|
|
55
|
+
File.dirname(File.expand_path(intent_dir))
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# The home (parent of `.plastic`) an intent dir belongs to, else the given
|
|
59
|
+
# fallback. A sandboxed store never resolves to the real Dir.home.
|
|
60
|
+
def home_for(intent_dir, home: Dir.home)
|
|
61
|
+
Worktree.home_from_store(store_for(intent_dir)) || home
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def global_store(home)
|
|
65
|
+
File.join(File.expand_path(home), ".plastic", "store")
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# The minimal hash Worktree.provision, release, and finish consume: the
|
|
69
|
+
# intent block plus, when asked, the derived worktree block.
|
|
70
|
+
def bridge_hash(intent_dir:, home: Dir.home, with_worktree: true)
|
|
71
|
+
dir = File.expand_path(intent_dir)
|
|
72
|
+
data = {
|
|
73
|
+
"intent" => { "id" => intent_id_for(dir), "dir" => File.basename(dir), "store" => store_for(dir) },
|
|
74
|
+
}
|
|
75
|
+
data["worktree"] = worktree_block(intent_dir: dir, home: home) if with_worktree
|
|
76
|
+
data
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# `{code, code_branch, provisioned}` derived from projects.yml and the
|
|
80
|
+
# intent id: the same path provision creates, `provisioned` iff it exists.
|
|
81
|
+
def worktree_block(intent_dir:, home: Dir.home)
|
|
82
|
+
dir = File.expand_path(intent_dir)
|
|
83
|
+
store = store_for(dir)
|
|
84
|
+
h = home_for(dir, home: home)
|
|
85
|
+
slug = Worktree.slug_for_store(store, home: h)
|
|
86
|
+
p = Worktree.paths(slug: slug, intent_id: intent_id_for(dir),
|
|
87
|
+
intent_slug: Worktree.slug_from_dir(dir), home: h)
|
|
88
|
+
code = p["code"]
|
|
89
|
+
provisioned = !blank?(code) && Dir.exist?(code)
|
|
90
|
+
{
|
|
91
|
+
"code" => provisioned ? code : nil,
|
|
92
|
+
"code_branch" => provisioned ? p["code_branch"] : nil,
|
|
93
|
+
"provisioned" => provisioned,
|
|
94
|
+
}
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# --- the pointer -------------------------------------------------------------
|
|
98
|
+
|
|
99
|
+
def pointer_path(session, home:)
|
|
100
|
+
store = global_store(home)
|
|
101
|
+
SessionLedger.pointer_path(store, SessionLedger.short_session_id(nil, session))
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def read_pointer(session, home:)
|
|
105
|
+
path = pointer_path(session, home: home)
|
|
106
|
+
File.exist?(path) ? File.read(path).strip : nil
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def write_pointer(session, value, home:)
|
|
110
|
+
store = global_store(home)
|
|
111
|
+
SessionLedger.ensure_tmp_root(store)
|
|
112
|
+
path = pointer_path(session, home: home)
|
|
113
|
+
FileUtils.mkdir_p(File.dirname(path))
|
|
114
|
+
File.write(path, "#{value}\n")
|
|
115
|
+
path
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# --- arm ---------------------------------------------------------------------
|
|
119
|
+
|
|
120
|
+
# Take an intent for `session`: acquire delivery.lock (stamped with the run
|
|
121
|
+
# mode and the provenance the caller knows), provision the code worktree
|
|
122
|
+
# (fail-open for a global-only or non-git intent), and point the session at
|
|
123
|
+
# the intent. Returns `{status:, lock:, worktree:, session:, pointer:}`.
|
|
124
|
+
# A held, stale, excluded, or corrupt lock returns that status with the
|
|
125
|
+
# lock data read and touches nothing.
|
|
126
|
+
def arm(intent_dir:, session:, mode: "auto", home: Dir.home, harness: nil,
|
|
127
|
+
agent: nil, model: nil, thread: nil, now: Time.now, runner: Worktree::ShellRunner.new,
|
|
128
|
+
host: Socket.gethostname)
|
|
129
|
+
raise ArgumentError, "mode must be auto or guided" unless %w[auto guided].include?(mode.to_s)
|
|
130
|
+
dir = File.expand_path(intent_dir)
|
|
131
|
+
key = resolve_session(session, store: store_for(dir), intent_id: intent_id_for(dir))
|
|
132
|
+
h = home_for(dir, home: home)
|
|
133
|
+
|
|
134
|
+
status, lock = Lock.acquire(dir, session: key, host: host, now: now,
|
|
135
|
+
harness: harness, agent: agent, model: model,
|
|
136
|
+
thread: thread, run_mode: mode.to_s)
|
|
137
|
+
unless %i[acquired owned].include?(status)
|
|
138
|
+
return { status: status, lock: lock, worktree: nil, session: key, pointer: nil }
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
data = bridge_hash(intent_dir: dir, home: h, with_worktree: false)
|
|
142
|
+
begin
|
|
143
|
+
Worktree.provision(data, home: h, runner: runner)
|
|
144
|
+
rescue StandardError => e
|
|
145
|
+
warn "plastic: worktree provision raised, continuing unprovisioned: #{e.message}"
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
# The pointer is what the capture and record hooks read; they key on the
|
|
149
|
+
# harness's real session id, so a derived key gets no pointer (nothing would
|
|
150
|
+
# ever read it). A pointer failure warns and never undoes an acquired lock.
|
|
151
|
+
pointer = nil
|
|
152
|
+
unless key == derive_key(store_for(dir), intent_id_for(dir))
|
|
153
|
+
begin
|
|
154
|
+
pointer = write_pointer(key, intent_id_for(dir), home: h)
|
|
155
|
+
rescue StandardError => e
|
|
156
|
+
warn "plastic: session pointer not written (#{e.message}); the lock is held regardless"
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
{ status: status, lock: lock, worktree: worktree_block(intent_dir: dir, home: h),
|
|
160
|
+
session: key, pointer: pointer }
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
# --- disarm ------------------------------------------------------------------
|
|
164
|
+
|
|
165
|
+
# Give the intent back: remove the worktree (when `remove`), release the
|
|
166
|
+
# lock as its recorded owner (falling back to `session`), and reset the
|
|
167
|
+
# session pointer to today's day id when it named this intent. Returns
|
|
168
|
+
# the lock release status (:released, :none, :not_owner, or :raised).
|
|
169
|
+
def disarm(intent_dir:, session:, home: Dir.home, runner: Worktree::ShellRunner.new,
|
|
170
|
+
remove: true, now: Time.now)
|
|
171
|
+
dir = File.expand_path(intent_dir)
|
|
172
|
+
h = home_for(dir, home: home)
|
|
173
|
+
key = resolve_session(session, store: store_for(dir), intent_id: intent_id_for(dir))
|
|
174
|
+
|
|
175
|
+
begin
|
|
176
|
+
Worktree.release(bridge_hash(intent_dir: dir, home: h), home: h, runner: runner, remove: remove)
|
|
177
|
+
rescue StandardError => e
|
|
178
|
+
warn "plastic: worktree release raised, continuing: #{e.message}"
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
lock = Lock.read(dir)
|
|
182
|
+
owner = lock && !blank?(lock["owner_session"]) ? lock["owner_session"] : key
|
|
183
|
+
release_status = begin
|
|
184
|
+
Lock.release(dir, session: owner)
|
|
185
|
+
rescue StandardError => e
|
|
186
|
+
warn "plastic: delivery lock release raised for #{dir}, continuing: #{e.message}"
|
|
187
|
+
:raised
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
begin
|
|
191
|
+
reset_pointer(key, intent_id_for(dir), home: h, now: now)
|
|
192
|
+
rescue StandardError => e
|
|
193
|
+
warn "plastic: session pointer not reset (#{e.message})"
|
|
194
|
+
end
|
|
195
|
+
release_status
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
# Point the session back at the day ledger, but only when it names `intent_id`.
|
|
199
|
+
def reset_pointer(session, intent_id, home:, now: Time.now)
|
|
200
|
+
current = read_pointer(session, home: home)
|
|
201
|
+
return false unless current == intent_id.to_s
|
|
202
|
+
write_pointer(session, SessionLedger.day_id(now), home: home)
|
|
203
|
+
true
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
# --- repair ------------------------------------------------------------------
|
|
207
|
+
|
|
208
|
+
# One idempotent repair (the lock half of the bridge's repair, removed in 2.0):
|
|
209
|
+
# remove a corrupt lock, back off from a fresh foreign lock (`held`), report
|
|
210
|
+
# a stale foreign lock (`stale`) for the explicit reclaim verb, keep and
|
|
211
|
+
# enrich an own lock, heartbeat a delegated one, acquire when none, and
|
|
212
|
+
# provision the worktree so the repaired intent has its checkout.
|
|
213
|
+
def repair(intent_dir:, session:, home: Dir.home, now: Time.now, harness: nil,
|
|
214
|
+
agent: nil, model: nil, thread: nil, run_mode: nil, hint_harness: nil,
|
|
215
|
+
runner: Worktree::ShellRunner.new)
|
|
216
|
+
dir = File.expand_path(intent_dir)
|
|
217
|
+
h = home_for(dir, home: home)
|
|
218
|
+
key = resolve_session(session, store: store_for(dir), intent_id: intent_id_for(dir))
|
|
219
|
+
actions = []
|
|
220
|
+
|
|
221
|
+
if Lock.corrupt?(dir)
|
|
222
|
+
File.delete(Lock.path(dir))
|
|
223
|
+
actions << "removed corrupt delivery.lock"
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
lock = Lock.read(dir)
|
|
227
|
+
if lock && !Lock.authorized?(lock, key)
|
|
228
|
+
if Lock.fresh?(dir, now: now)
|
|
229
|
+
return { "status" => "held", "owner" => lock["owner_session"],
|
|
230
|
+
"actions" => actions, "session" => key }
|
|
231
|
+
end
|
|
232
|
+
return { "status" => "stale", "owner" => lock["owner_session"],
|
|
233
|
+
"actions" => actions, "session" => key,
|
|
234
|
+
"hint" => "run #{Lock.skill_ref('plastic-doctor', harness: hint_harness || harness)} " \
|
|
235
|
+
"reclaim the lock to take over with an audit" }
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
mode = blank?(run_mode) ? (lock && lock["run_mode"]) : run_mode.to_s
|
|
239
|
+
if lock
|
|
240
|
+
if lock["owner_session"].to_s == key.to_s
|
|
241
|
+
lock_data = lock.dup
|
|
242
|
+
{ "owner_harness" => harness, "owner_agent" => agent, "owner_model" => model,
|
|
243
|
+
"owner_thread" => thread, "run_mode" => mode }.each do |field, value|
|
|
244
|
+
lock_data[field] = value.to_s unless blank?(value)
|
|
245
|
+
end
|
|
246
|
+
Lock.write(dir, lock_data)
|
|
247
|
+
Lock.heartbeat(dir, session: key, now: now)
|
|
248
|
+
else
|
|
249
|
+
Lock.heartbeat(dir, session: key, now: now)
|
|
250
|
+
lock_data = Lock.read(dir)
|
|
251
|
+
end
|
|
252
|
+
role = lock_data["owner_session"].to_s == key.to_s ? "owner" : "delegate"
|
|
253
|
+
actions << "lock kept (#{role})"
|
|
254
|
+
else
|
|
255
|
+
status, lock_data = Lock.acquire(dir, session: key, now: now, harness: harness,
|
|
256
|
+
agent: agent, model: model, thread: thread,
|
|
257
|
+
run_mode: mode)
|
|
258
|
+
actions << "lock #{status}"
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
begin
|
|
262
|
+
Worktree.provision(bridge_hash(intent_dir: dir, home: h, with_worktree: false), home: h, runner: runner)
|
|
263
|
+
rescue StandardError => e
|
|
264
|
+
warn "plastic: worktree provision raised during repair, continuing unprovisioned: #{e.message}"
|
|
265
|
+
end
|
|
266
|
+
actions << "worktree #{worktree_block(intent_dir: dir, home: h)['provisioned'] ? 'present' : 'absent'}"
|
|
267
|
+
actions << "stage #{Savepoint.derive_stage(dir)}"
|
|
268
|
+
|
|
269
|
+
{ "status" => "repaired", "actions" => actions, "lock" => lock_data, "session" => key }
|
|
270
|
+
end
|
|
271
|
+
|
|
272
|
+
# The intent directory this session's pointer names, searched in the given
|
|
273
|
+
# stores in order; nil when the pointer is absent, empty, or a day id.
|
|
274
|
+
def intent_dir_from_pointer(session, home:, stores:)
|
|
275
|
+
current = read_pointer(session, home: home)
|
|
276
|
+
return nil if blank?(current) || SessionLedger.valid_day_id?(current)
|
|
277
|
+
stores.each do |store|
|
|
278
|
+
match = Dir.glob(File.join(store, "#{current}--*")).find { |p| File.directory?(p) }
|
|
279
|
+
return match if match
|
|
280
|
+
end
|
|
281
|
+
nil
|
|
282
|
+
end
|
|
283
|
+
end
|