@zalom/plastic 1.14.0 → 2.0.0-alpha.1
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/config_asks.yml +38 -0
- package/hooks/capture +4 -0
- package/hooks/check-update +12 -3
- package/hooks/close +5 -0
- package/hooks/hooks.json +9 -49
- package/hooks/record +4 -0
- package/package.json +3 -2
- package/scripts/agent-report +8 -9
- package/scripts/append-ledger +195 -0
- package/scripts/codex-hook +101 -95
- package/scripts/dashboard.rb +4 -4
- 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 +34 -0
- package/scripts/hook-record +149 -0
- package/scripts/hook-session-start +95 -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/doctor_core.rb +17 -88
- package/scripts/lib/doctor_session_ledger.rb +106 -0
- package/scripts/lib/exec_worktree.rb +14 -60
- 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 +19 -30
- 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 +84 -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/rebuild-graph +1 -1
- package/scripts/roadmap-next +2 -2
- package/scripts/rollback.rb +42 -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/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 +131 -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/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
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
|
|
@@ -0,0 +1,316 @@
|
|
|
1
|
+
# encoding: UTF-8
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require "fileutils"
|
|
5
|
+
require "time"
|
|
6
|
+
require_relative "scaffold_intent"
|
|
7
|
+
require_relative "savepoint"
|
|
8
|
+
require_relative "intent_validator"
|
|
9
|
+
require_relative "worktree"
|
|
10
|
+
|
|
11
|
+
# BackfillIntent (intent 308) - write the four judgment documents of an intent from its
|
|
12
|
+
# record at intent end. The record is what an intent already carries by the time it
|
|
13
|
+
# closes: the intent file (`## Intent`, `### Decisions`, `## Insights`), checklist.md,
|
|
14
|
+
# and the diff on its own code worktree. Every section written here is a verbatim copy
|
|
15
|
+
# or a mechanical rendering of one of those; a section with no source keeps the
|
|
16
|
+
# template's own stub text, so nothing here invents prose. The one value that comes from
|
|
17
|
+
# the caller is outcome.md's `disposition:`, the closer's explicit assertion (spec D11).
|
|
18
|
+
#
|
|
19
|
+
# A target is backfilled only when it is missing or still the scaffold placeholder: the
|
|
20
|
+
# first line is the sentinel AND the body under it is nothing but the template
|
|
21
|
+
# (`edited_sentinel?`). A sentinel over hand-written lines counts as real. actions/ is
|
|
22
|
+
# backfilled as ACTION_1.md only when `Savepoint.has_real_action?` is false. A real file
|
|
23
|
+
# is never touched and there is no force flag. Every written file carries a marker
|
|
24
|
+
# comment on the line after its title, never the sentinel, so every reader treats it as
|
|
25
|
+
# real. A zero-byte spec.md or plan.md reads as real here exactly as it does in
|
|
26
|
+
# `Savepoint.stage_file_present?` (spec D1), so the doctor surfaces never disagree.
|
|
27
|
+
#
|
|
28
|
+
# Pure and dependency-injected like ScaffoldIntent: never exits, never reads ARGV or ENV
|
|
29
|
+
# (only the ambient Dir.home default), and takes the git seam as `runner:` so tests drive
|
|
30
|
+
# it in process with a fake runner.
|
|
31
|
+
module BackfillIntent
|
|
32
|
+
module_function
|
|
33
|
+
|
|
34
|
+
TARGETS = ["spec.md", "plan.md", "actions/ACTION_1.md", "outcome.md"].freeze
|
|
35
|
+
TEMPLATED = ["spec.md", "plan.md", "outcome.md"].freeze
|
|
36
|
+
MARKER_PREFIX = "<!-- backfilled from the record by end-intent on "
|
|
37
|
+
ITEM_RE = /\A\s*- \[( |x|X)\] /.freeze
|
|
38
|
+
TITLE_RE = /\A# /.freeze
|
|
39
|
+
STUB_SECTIONS = {
|
|
40
|
+
"## Goals" => "- ...\n", "## Non-Goals" => "- ...\n",
|
|
41
|
+
"## Approach" => "(the chosen approach, in prose)\n",
|
|
42
|
+
"## Alternatives Considered" => "- <alternative>: not chosen because ...\n",
|
|
43
|
+
}.freeze
|
|
44
|
+
|
|
45
|
+
def marker(now)
|
|
46
|
+
"#{MARKER_PREFIX}#{now.utc.iso8601} -->"
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# --- templates ---------------------------------------------------------------------
|
|
50
|
+
|
|
51
|
+
# { "spec.md" => text, ... } for the templates that exist, else {}.
|
|
52
|
+
def load_templates(templates_dir: nil, home: Dir.home)
|
|
53
|
+
dir = templates_dir || ScaffoldIntent.resolve_templates_dir(home: home)
|
|
54
|
+
return {} if dir.nil?
|
|
55
|
+
|
|
56
|
+
TEMPLATED.each_with_object({}) do |name, acc|
|
|
57
|
+
path = File.join(dir, name)
|
|
58
|
+
acc[name] = File.read(path) if File.exist?(path)
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# --- target selection --------------------------------------------------------------
|
|
63
|
+
|
|
64
|
+
# True iff `path` starts with the sentinel and its body carries a non-blank, non-title
|
|
65
|
+
# line that the template for this file does not: someone wrote under the sentinel.
|
|
66
|
+
# With no template known, any body line at all means edited.
|
|
67
|
+
def edited_sentinel?(path, template_text, known_lines: [])
|
|
68
|
+
lines = File.read(path).lines
|
|
69
|
+
return false unless lines.first.to_s.chomp == Savepoint::PLACEHOLDER_SENTINEL
|
|
70
|
+
|
|
71
|
+
body = lines.drop(1).map(&:strip).reject { |l| l.empty? || l.match?(TITLE_RE) }
|
|
72
|
+
return body.any? if template_text.nil?
|
|
73
|
+
|
|
74
|
+
known = template_text.lines.map(&:strip).reject(&:empty?) + known_lines.map(&:strip)
|
|
75
|
+
(body - known).any?
|
|
76
|
+
rescue StandardError
|
|
77
|
+
true
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# { targets: [rel], edited: [rel] }: the targets that need writing, and the sentinel
|
|
81
|
+
# files that were left alone because their body was edited.
|
|
82
|
+
def classify(intent_dir, templates: {}, known_lines: [])
|
|
83
|
+
targets = []
|
|
84
|
+
edited = []
|
|
85
|
+
TEMPLATED.each do |rel|
|
|
86
|
+
path = File.join(intent_dir, rel)
|
|
87
|
+
next if Savepoint.stage_file_present?(path)
|
|
88
|
+
|
|
89
|
+
if File.exist?(path) && edited_sentinel?(path, templates[rel], known_lines: known_lines)
|
|
90
|
+
edited << rel
|
|
91
|
+
else
|
|
92
|
+
targets << rel
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
unless Savepoint.has_real_action?(intent_dir)
|
|
96
|
+
action = File.join(intent_dir, "actions", "ACTION_1.md")
|
|
97
|
+
if File.exist?(action) && !Savepoint.stage_file_present?(action) && edited_sentinel?(action, nil)
|
|
98
|
+
edited << "actions/ACTION_1.md"
|
|
99
|
+
elsif !File.exist?(action) || !Savepoint.stage_file_present?(action)
|
|
100
|
+
targets << "actions/ACTION_1.md"
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
{ targets: TARGETS & targets, edited: TARGETS & edited }
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def targets_for(intent_dir, templates_dir: nil, home: Dir.home)
|
|
107
|
+
classify(intent_dir, templates: load_templates(templates_dir: templates_dir, home: home))[:targets]
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# --- reading the record (pure over file contents) ---------------------------------
|
|
111
|
+
|
|
112
|
+
def strip_edges(lines)
|
|
113
|
+
ScaffoldIntent.strip_blank_edges(lines)
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def section_lines(text, heading)
|
|
117
|
+
body = ScaffoldIntent.sections_from(text)[heading]
|
|
118
|
+
return [] if body.nil?
|
|
119
|
+
|
|
120
|
+
strip_edges(body.lines.map(&:rstrip)).reject { |l| l.start_with?("<!--") && l.end_with?("-->") }
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# Every GFM checkbox item of checklist.md with its section and done flag. A continuation
|
|
124
|
+
# line (indented, not an item, not a heading, not a table row) stays attached to the item
|
|
125
|
+
# above it so a wrapped item round-trips verbatim.
|
|
126
|
+
def checklist_items(text)
|
|
127
|
+
items = []
|
|
128
|
+
section = nil
|
|
129
|
+
text.each_line do |raw|
|
|
130
|
+
line = raw.rstrip
|
|
131
|
+
if line.start_with?("## ")
|
|
132
|
+
section = line
|
|
133
|
+
elsif (m = ITEM_RE.match(line))
|
|
134
|
+
items << { line: line, done: m[1] != " ", section: section }
|
|
135
|
+
elsif items.any? && line.start_with?(" ") && !line.start_with?(" |") && section == items.last[:section]
|
|
136
|
+
items.last[:line] = "#{items.last[:line]}\n#{line}"
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
items
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def session_log_lines(text)
|
|
143
|
+
section_lines(text, "## Session Log").select { |l| l.start_with?("|") }
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
# [record, nil] or [nil, message] when the intent file is missing. `record[:notes]`
|
|
147
|
+
# names every source that was absent; an absent source never stops the write.
|
|
148
|
+
def read_record(intent_dir)
|
|
149
|
+
intent_file = Savepoint.intent_file(intent_dir)
|
|
150
|
+
return [nil, "the intent file is missing at #{intent_file}"] unless File.exist?(intent_file)
|
|
151
|
+
|
|
152
|
+
content = File.read(intent_file)
|
|
153
|
+
fm = IntentValidator.parse_frontmatter_text(content)
|
|
154
|
+
name = fm.is_a?(Hash) ? fm["intent"].to_s.strip : ""
|
|
155
|
+
notes = []
|
|
156
|
+
if name.empty?
|
|
157
|
+
name = File.basename(intent_dir)
|
|
158
|
+
notes << "the intent file has no frontmatter intent name; the directory name stands in"
|
|
159
|
+
end
|
|
160
|
+
decisions, = ScaffoldIntent.extract_decisions(content)
|
|
161
|
+
notes << "the intent file has no ### Decisions list; spec.md says so" if decisions.nil?
|
|
162
|
+
|
|
163
|
+
checklist = File.join(intent_dir, "checklist.md")
|
|
164
|
+
checklist_text = ""
|
|
165
|
+
if Savepoint.stage_file_present?(checklist)
|
|
166
|
+
checklist_text = File.read(checklist)
|
|
167
|
+
else
|
|
168
|
+
notes << "checklist.md is missing or still a placeholder; the item sections say so"
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
[{
|
|
172
|
+
name: name,
|
|
173
|
+
intent_body: section_lines(content, "## Intent"),
|
|
174
|
+
decisions: decisions,
|
|
175
|
+
insights: section_lines(content, "## Insights"),
|
|
176
|
+
items: checklist_items(checklist_text),
|
|
177
|
+
session_log: session_log_lines(checklist_text),
|
|
178
|
+
notes: notes,
|
|
179
|
+
}, nil]
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
# --- rendering (pure) ---------------------------------------------------------------
|
|
183
|
+
|
|
184
|
+
def block(lines, empty)
|
|
185
|
+
lines.empty? ? "#{empty}\n" : "#{lines.join("\n")}\n"
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
def template_stub(template_text, heading)
|
|
189
|
+
body = template_text && ScaffoldIntent.sections_from(template_text)[heading]
|
|
190
|
+
body = STUB_SECTIONS.fetch(heading, "") if body.nil? || body.strip.empty?
|
|
191
|
+
"#{body.rstrip}\n"
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
def render(record, disposition:, summary:, verification:, now:, templates: {})
|
|
195
|
+
mark = marker(now)
|
|
196
|
+
items = record[:items].map { |i| i[:line] }
|
|
197
|
+
done = record[:items].select { |i| i[:done] }.map { |i| i[:line] }
|
|
198
|
+
open = record[:items].reject { |i| i[:done] }.map { |i| i[:line] }
|
|
199
|
+
decisions = record[:decisions].nil? ? "(none recorded in the intent file)\n" : record[:decisions]
|
|
200
|
+
decisions += "\n" unless decisions.end_with?("\n")
|
|
201
|
+
|
|
202
|
+
spec = +"# Spec: #{record[:name]}\n#{mark}\n\n"
|
|
203
|
+
spec << "## Problem\n#{block(record[:intent_body], '(not recorded in the intent file)')}\n"
|
|
204
|
+
STUB_SECTIONS.each_key { |h| spec << "#{h}\n#{template_stub(templates['spec.md'], h)}\n" }
|
|
205
|
+
spec << "## Decisions\n#{decisions}\n"
|
|
206
|
+
spec << "## Acceptance Criteria\n#{block(items, '(no checklist items recorded)')}\n"
|
|
207
|
+
spec << "## Open Questions\nNone\n"
|
|
208
|
+
|
|
209
|
+
plan = +"# Plan: #{record[:name]}\n#{mark}\n\n"
|
|
210
|
+
plan << "## Goal\n#{record[:name]}\n\n"
|
|
211
|
+
plan << "## Steps\n#{block(items, '(no checklist items recorded)')}\n"
|
|
212
|
+
plan << "## Notes\n#{block(record[:insights], '(no insights recorded)')}"
|
|
213
|
+
|
|
214
|
+
action = +"# ACTION_1: #{record[:name]}\n#{mark}\n\n"
|
|
215
|
+
action << "## Items\n#{block(items, '(no checklist items recorded)')}\n"
|
|
216
|
+
action << "## Session Log\n#{block(record[:session_log], '(none)')}"
|
|
217
|
+
|
|
218
|
+
summary_text = summary.to_s.strip
|
|
219
|
+
summary_text = "(no summary given at close)" if summary_text.empty?
|
|
220
|
+
outcome = +"---\ndisposition: #{disposition}\n---\n# Outcome: #{record[:name]}\n#{mark}\n\n"
|
|
221
|
+
outcome << "## Summary\n#{summary_text}\n\n"
|
|
222
|
+
outcome << "## Delivered\n#{block(done, '(no completed checklist items recorded)')}\n"
|
|
223
|
+
outcome << "## Verification\n#{verification.to_s.strip.empty? ? "(no diff available)\n" : verification}\n"
|
|
224
|
+
outcome << "## Follow-ups\n#{block(open, 'None')}"
|
|
225
|
+
|
|
226
|
+
{ "spec.md" => spec, "plan.md" => plan, "actions/ACTION_1.md" => action, "outcome.md" => outcome }
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
# --- the diff, from this intent's own code worktree only -----------------------------
|
|
230
|
+
|
|
231
|
+
# The provisioned code worktree for this intent when it exists on disk, else nil. Never
|
|
232
|
+
# the current directory's repo: a store-only intent closed from an unrelated checkout
|
|
233
|
+
# must not report that checkout's diff.
|
|
234
|
+
def worktree_dir(store:, id:, intent_dir:, home: Dir.home)
|
|
235
|
+
wt_home = Worktree.home_from_store(store) || home
|
|
236
|
+
slug = Worktree.slug_for_store(store, home: wt_home)
|
|
237
|
+
return nil if slug.nil?
|
|
238
|
+
|
|
239
|
+
intent_slug = File.basename(intent_dir).split("--", 2).last
|
|
240
|
+
code = Worktree.paths(slug: slug, intent_id: id, intent_slug: intent_slug, home: wt_home)["code"]
|
|
241
|
+
code && Dir.exist?(code) ? code : nil
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
def verification_body(store:, id:, intent_dir:, home:, runner:)
|
|
245
|
+
repo = worktree_dir(store: store, id: id, intent_dir: intent_dir, home: home)
|
|
246
|
+
return "Diffstat unavailable: this intent provisioned no code worktree\n" if repo.nil?
|
|
247
|
+
|
|
248
|
+
base = ScaffoldIntent.detect_base_branch(repo, runner: runner)
|
|
249
|
+
return "Diffstat unavailable: no base branch could be detected (no origin/HEAD, main, or master)\n" if base.nil?
|
|
250
|
+
|
|
251
|
+
stat, err = ScaffoldIntent.diffstat(repo, base, runner: runner)
|
|
252
|
+
return "Diffstat unavailable: #{err}\n" if stat.nil?
|
|
253
|
+
|
|
254
|
+
"Diffstat against #{base}:\n```\n#{stat}#{stat.end_with?("\n") ? "" : "\n"}```\n"
|
|
255
|
+
rescue StandardError => e
|
|
256
|
+
"Diffstat unavailable: #{e.message}\n"
|
|
257
|
+
end
|
|
258
|
+
|
|
259
|
+
# --- writing, atomic inside the intent dir -------------------------------------------
|
|
260
|
+
|
|
261
|
+
def write_atomic(intent_dir, relative, content)
|
|
262
|
+
target = File.join(intent_dir, relative)
|
|
263
|
+
FileUtils.mkdir_p(File.dirname(target))
|
|
264
|
+
tmp = File.join(File.dirname(target), ".filing-#{File.basename(target)}")
|
|
265
|
+
File.write(tmp, content)
|
|
266
|
+
File.rename(tmp, target)
|
|
267
|
+
target
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
# A `.filing-*` left by an earlier crash would otherwise ride into the store commit.
|
|
271
|
+
def remove_stale_filing(intent_dir)
|
|
272
|
+
Dir.glob([File.join(intent_dir, ".filing-*"), File.join(intent_dir, "actions", ".filing-*")]).each do |f|
|
|
273
|
+
File.delete(f) if File.file?(f)
|
|
274
|
+
end
|
|
275
|
+
end
|
|
276
|
+
|
|
277
|
+
# Returns { written: [rel], skipped: [rel], notes: [String] }. `skipped` lists the
|
|
278
|
+
# targets that already carry real content (an edited sentinel among them, named in
|
|
279
|
+
# `notes`). `notes` names every absent source; an absent source never stops the write.
|
|
280
|
+
# One savepoint line, `Exec backfilled <list>`, records the fact (idempotent per list).
|
|
281
|
+
def run(intent_dir:, store:, id:, disposition:, summary: nil, home: Dir.home,
|
|
282
|
+
runner: Worktree::ShellRunner.new, templates_dir: nil, now: Time.now)
|
|
283
|
+
result = { written: [], skipped: [], notes: [] }
|
|
284
|
+
remove_stale_filing(intent_dir)
|
|
285
|
+
|
|
286
|
+
templates = load_templates(templates_dir: templates_dir, home: home)
|
|
287
|
+
record, err = read_record(intent_dir)
|
|
288
|
+
if record.nil?
|
|
289
|
+
result[:notes] << err
|
|
290
|
+
result[:skipped] = TARGETS - classify(intent_dir, templates: templates)[:targets]
|
|
291
|
+
return result
|
|
292
|
+
end
|
|
293
|
+
|
|
294
|
+
classified = classify(intent_dir, templates: templates, known_lines: [record[:name]])
|
|
295
|
+
targets = classified[:targets]
|
|
296
|
+
result[:skipped] = TARGETS - targets
|
|
297
|
+
classified[:edited].each do |rel|
|
|
298
|
+
result[:notes] << "#{rel} carries a sentinel over hand-written content; left untouched"
|
|
299
|
+
end
|
|
300
|
+
result[:notes].concat(record[:notes])
|
|
301
|
+
return result if targets.empty?
|
|
302
|
+
|
|
303
|
+
verification = nil
|
|
304
|
+
if targets.include?("outcome.md")
|
|
305
|
+
verification = verification_body(store: store, id: id, intent_dir: intent_dir, home: home, runner: runner)
|
|
306
|
+
end
|
|
307
|
+
docs = render(record, disposition: disposition, summary: summary, verification: verification,
|
|
308
|
+
now: now, templates: templates)
|
|
309
|
+
targets.each do |rel|
|
|
310
|
+
write_atomic(intent_dir, rel, docs.fetch(rel))
|
|
311
|
+
result[:written] << rel
|
|
312
|
+
end
|
|
313
|
+
Savepoint.append_savepoint_line(intent_dir, "Exec", "backfilled #{result[:written].join(', ')}", now)
|
|
314
|
+
result
|
|
315
|
+
end
|
|
316
|
+
end
|