@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
|
@@ -0,0 +1,320 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# encoding: UTF-8
|
|
3
|
+
# frozen_string_literal: true
|
|
4
|
+
|
|
5
|
+
# Savepoint: the intent-directory savepoint ledger and the stage derivation it
|
|
6
|
+
# rests on (intent 303, split out of bridge.rb). Everything here is derived from
|
|
7
|
+
# the files on disk inside one intent directory: which lifecycle files are real
|
|
8
|
+
# (not the placeholder sentinel), which stage that makes, and the append-only
|
|
9
|
+
# savepoint.md ledger that records each milestone. No session, no bridge, no
|
|
10
|
+
# lock: a caller that only wants to append a ledger line or ask "which stage is
|
|
11
|
+
# this intent at" loads this file and nothing else. bridge.rb (the session
|
|
12
|
+
# pointer, arm and disarm, lock repair) requires this file; this file never
|
|
13
|
+
# requires the bridge.
|
|
14
|
+
require "fileutils"
|
|
15
|
+
require "time" # Time#iso8601 for the ledger timestamps
|
|
16
|
+
|
|
17
|
+
module Savepoint
|
|
18
|
+
# Placeholder sentinel (intent 60b). A scaffolded lifecycle file
|
|
19
|
+
# (spec.md/plan.md/checklist.md/outcome.md) carries this exact string as its
|
|
20
|
+
# first line until an agent fills the file and deletes the sentinel. The
|
|
21
|
+
# sentinel is the "stage not reached yet" marker, so stage detection treats a
|
|
22
|
+
# sentinel-marked file as absent (see stage_file_present?). The intent file
|
|
23
|
+
# (<id>--<slug>.md) is never sentineled; it is born complete.
|
|
24
|
+
PLACEHOLDER_SENTINEL = "<!-- plastic:placeholder -->"
|
|
25
|
+
|
|
26
|
+
def self.intent_file(intent_dir)
|
|
27
|
+
dir_name = File.basename(intent_dir)
|
|
28
|
+
"#{intent_dir}/#{dir_name}.md"
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Walk up from file_path; return the first ancestor that looks like an intent
|
|
32
|
+
# directory (`.../store/<id>--<slug>`), else nil. Used to derive the savepoint
|
|
33
|
+
# target without needing a bridge. The input is always a file inside the intent
|
|
34
|
+
# dir (never the dir itself), so the walk-up starts at its parent.
|
|
35
|
+
def self.intent_dir_for(file_path)
|
|
36
|
+
dir = File.expand_path(file_path)
|
|
37
|
+
loop do
|
|
38
|
+
parent = File.dirname(dir)
|
|
39
|
+
break if parent == dir # reached filesystem root
|
|
40
|
+
dir = parent
|
|
41
|
+
return dir if dir.match?(%r{/store/[^/]+--[^/]+\z})
|
|
42
|
+
end
|
|
43
|
+
nil
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# True iff a lifecycle file is PRESENT AND REAL: it exists and its first line is
|
|
47
|
+
# not the placeholder sentinel. Reads only the file head (never the whole file)
|
|
48
|
+
# so the dashboard stays fast across many intents. Exact first-line match only,
|
|
49
|
+
# so a real file that merely contains an HTML comment later is unaffected, and a
|
|
50
|
+
# partially-edited sentinel reads as real rather than sticking as a placeholder.
|
|
51
|
+
def self.stage_file_present?(path)
|
|
52
|
+
return false unless File.exist?(path)
|
|
53
|
+
first = File.open(path, &:gets)
|
|
54
|
+
return true if first.nil? # empty file: present, not a sentinel
|
|
55
|
+
first.chomp != PLACEHOLDER_SENTINEL
|
|
56
|
+
rescue StandardError
|
|
57
|
+
File.exist?(path)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# True iff actions/ holds AT LEAST ONE real action file: a non-empty *.md whose
|
|
61
|
+
# first line is not the placeholder sentinel. A `.gitkeep` (no .md extension)
|
|
62
|
+
# never counts, an empty *.md never counts, and a sentinel-only *.md never
|
|
63
|
+
# counts. Pure and side-effect-free so the gate stays unit-testable. Fail-open:
|
|
64
|
+
# a missing actions/ dir globs to nothing and returns false (the gate then
|
|
65
|
+
# reports it needs a real action file); it never raises.
|
|
66
|
+
def self.has_real_action?(intent_dir)
|
|
67
|
+
Dir.glob("#{intent_dir}/actions/*.md").any? do |f|
|
|
68
|
+
File.file?(f) && File.size(f) > 0 && stage_file_present?(f)
|
|
69
|
+
end
|
|
70
|
+
rescue StandardError
|
|
71
|
+
false
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def self.derive_stage(intent_dir)
|
|
75
|
+
return "done" if stage_file_present?("#{intent_dir}/outcome.md")
|
|
76
|
+
if stage_file_present?("#{intent_dir}/plan.md") &&
|
|
77
|
+
has_real_action?(intent_dir) &&
|
|
78
|
+
stage_file_present?("#{intent_dir}/checklist.md")
|
|
79
|
+
return "exec"
|
|
80
|
+
end
|
|
81
|
+
return "how" if stage_file_present?("#{intent_dir}/spec.md")
|
|
82
|
+
return "why" if File.exist?(intent_file(intent_dir))
|
|
83
|
+
"what"
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def self.has_files(intent_dir)
|
|
87
|
+
files = []
|
|
88
|
+
ifile = File.basename(intent_file(intent_dir))
|
|
89
|
+
files << ifile if File.exist?("#{intent_dir}/#{ifile}")
|
|
90
|
+
["spec.md", "plan.md", "checklist.md", "outcome.md"].each do |f|
|
|
91
|
+
files << f if stage_file_present?("#{intent_dir}/#{f}")
|
|
92
|
+
end
|
|
93
|
+
files << "actions/" if has_real_action?(intent_dir)
|
|
94
|
+
files
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def self.missing_for_stage(stage, intent_dir = nil)
|
|
98
|
+
ifile = intent_dir ? File.basename(intent_file(intent_dir)) : "intent.md"
|
|
99
|
+
case stage
|
|
100
|
+
when "what" then [ifile]
|
|
101
|
+
when "why" then ["spec.md"]
|
|
102
|
+
when "how" then ["plan.md", "actions/", "checklist.md"]
|
|
103
|
+
when "exec" then ["outcome.md"]
|
|
104
|
+
else []
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# --- Cycle-step savepoint ledger (intent 34) ------------------------------
|
|
109
|
+
#
|
|
110
|
+
# savepoint.md is a deterministic, append-only, one-line-per-milestone ledger
|
|
111
|
+
# (newest at the bottom). It is sugar on top of the conventions: derived from
|
|
112
|
+
# files-on-disk, rebuildable, never a source of truth. Milestones are
|
|
113
|
+
# file-event boundaries only; action/resource files record nothing.
|
|
114
|
+
|
|
115
|
+
SAVEPOINT_FILE = "savepoint.md"
|
|
116
|
+
|
|
117
|
+
# Map a written filename to [stage_label, milestone_text], or nil if the file
|
|
118
|
+
# is not a lifecycle milestone.
|
|
119
|
+
def self.savepoint_milestone(intent_dir, basename)
|
|
120
|
+
return ["What", basename] if basename == File.basename(intent_file(intent_dir))
|
|
121
|
+
|
|
122
|
+
case basename
|
|
123
|
+
when "spec.md" then ["Why", "spec.md created"]
|
|
124
|
+
when "plan.md" then ["How", "plan.md created"]
|
|
125
|
+
when "checklist.md" then ["How", "checklist.md created"]
|
|
126
|
+
when "outcome.md" then ["Exec", "outcome.md created"]
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
# Milestones already recorded in the ledger (field 3 of each line).
|
|
131
|
+
def self.savepoint_recorded_milestones(intent_dir)
|
|
132
|
+
f = File.join(intent_dir, SAVEPOINT_FILE)
|
|
133
|
+
return [] unless File.exist?(f)
|
|
134
|
+
File.read(f).each_line.map do |line|
|
|
135
|
+
parts = line.strip.split(/\s{2,}/)
|
|
136
|
+
parts.length >= 3 ? parts[2] : nil
|
|
137
|
+
end.compact
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
# (stage, milestone) pairs already recorded in the ledger. The pair (not the
|
|
141
|
+
# milestone text alone) is the dedup key, because state-from-ledger lines like
|
|
142
|
+
# `Why started` and `How started` share the milestone text "started" while
|
|
143
|
+
# being distinct events (intent 81).
|
|
144
|
+
def self.savepoint_recorded_pairs(intent_dir)
|
|
145
|
+
f = File.join(intent_dir, SAVEPOINT_FILE)
|
|
146
|
+
return [] unless File.exist?(f)
|
|
147
|
+
File.read(f).each_line.filter_map do |line|
|
|
148
|
+
parts = line.strip.split(/\s{2,}/)
|
|
149
|
+
parts.length >= 3 ? [parts[1], parts[2]] : nil
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
# Append one ledger line for (stage, milestone) unless that pair is already
|
|
154
|
+
# recorded. The single append primitive shared by every line class. Returns
|
|
155
|
+
# true when a line was written, false when it was a no-op.
|
|
156
|
+
def self.append_savepoint_line(intent_dir, stage, milestone, now)
|
|
157
|
+
return false if savepoint_recorded_pairs(intent_dir).include?([stage, milestone])
|
|
158
|
+
line = "#{now.utc.iso8601} #{stage} #{milestone}\n"
|
|
159
|
+
File.open(File.join(intent_dir, SAVEPOINT_FILE), "a") { |io| io.write(line) }
|
|
160
|
+
true
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
# Append the artifact-landing milestone for file_path if (and only if) it is a
|
|
164
|
+
# milestone not already recorded. Returns true when a line was written.
|
|
165
|
+
def self.append_savepoint(intent_dir, file_path, now: Time.now)
|
|
166
|
+
basename = File.basename(file_path)
|
|
167
|
+
stage, milestone = savepoint_milestone(intent_dir, basename)
|
|
168
|
+
return false unless milestone
|
|
169
|
+
# A sentinel-marked lifecycle file logs NO milestone (the stage is not real
|
|
170
|
+
# yet). The intent file is never sentineled, so it still logs its What line.
|
|
171
|
+
return false unless stage_file_present?(File.join(intent_dir, basename))
|
|
172
|
+
|
|
173
|
+
append_savepoint_line(intent_dir, stage, milestone, now)
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
# --- State-from-ledger: pre-stage, exec-start, and terminal lines (81) ------
|
|
177
|
+
#
|
|
178
|
+
# On top of intent 34's artifact-landing milestones, the ledger gains:
|
|
179
|
+
# - `started` lines, one per cycle stage entry (pre-stage, written by the
|
|
180
|
+
# PreToolUse savepoint hook the moment a stage's artifact is first written);
|
|
181
|
+
# - an `Exec started` companion emitted when checklist.md lands;
|
|
182
|
+
# - a terminal `Done delivered|abandoned` line written by the completion path.
|
|
183
|
+
# None of these are derivable from files on disk, so they are deliberately NOT
|
|
184
|
+
# part of savepoint_milestone and are never regenerated by rebuild_savepoint:
|
|
185
|
+
# a rebuilt ledger is the file-landing skeleton, the live ledger is richer.
|
|
186
|
+
|
|
187
|
+
# Map a written filename to the [stage, "started"] pre-stage milestone, or nil.
|
|
188
|
+
# spec.md => entering Why, plan.md => entering How. checklist.md/outcome.md do
|
|
189
|
+
# not open a stage (checklist's Exec-start is the append_exec_started companion).
|
|
190
|
+
def self.savepoint_started_milestone(basename)
|
|
191
|
+
case basename
|
|
192
|
+
when "spec.md" then ["Why", "started"]
|
|
193
|
+
when "plan.md" then ["How", "started"]
|
|
194
|
+
end
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
# Append the pre-stage `started` line for file_path, iff: the basename opens a
|
|
198
|
+
# stage, the stage is genuinely starting (its artifact is not yet a REAL file,
|
|
199
|
+
# so a sentinel placeholder still counts as "starting"), and the pair is not
|
|
200
|
+
# already recorded. Returns true when a line was written.
|
|
201
|
+
def self.append_started_savepoint(intent_dir, file_path, now: Time.now)
|
|
202
|
+
basename = File.basename(file_path)
|
|
203
|
+
stage, milestone = savepoint_started_milestone(basename)
|
|
204
|
+
return false unless milestone
|
|
205
|
+
return false if stage_file_present?(File.join(intent_dir, basename))
|
|
206
|
+
|
|
207
|
+
append_savepoint_line(intent_dir, stage, milestone, now)
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
# Append the `Exec started` companion (emitted when checklist.md lands, in the
|
|
211
|
+
# same PostToolUse event as the `How checklist.md created` line). Idempotent.
|
|
212
|
+
def self.append_exec_started(intent_dir, now: Time.now)
|
|
213
|
+
append_savepoint_line(intent_dir, "Exec", "started", now)
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
TERMINAL_DISPOSITIONS = %w[delivered abandoned].freeze
|
|
217
|
+
|
|
218
|
+
# Append the terminal bookend `Done delivered|abandoned`, written by the
|
|
219
|
+
# completion path when an intent transfers to INDEX's Completed/Abandoned
|
|
220
|
+
# section. Idempotent per disposition. Raises on an unknown disposition.
|
|
221
|
+
def self.append_terminal_savepoint(intent_dir, disposition, now: Time.now)
|
|
222
|
+
unless TERMINAL_DISPOSITIONS.include?(disposition)
|
|
223
|
+
raise ArgumentError,
|
|
224
|
+
"disposition must be one of #{TERMINAL_DISPOSITIONS.join(', ')}, got #{disposition.inspect}"
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
append_savepoint_line(intent_dir, "Done", disposition, now)
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
# Reconstruct the ledger from files on disk (timestamps from mtimes), in
|
|
231
|
+
# stage order, overwriting savepoint.md. Returns the number of lines written.
|
|
232
|
+
# A Plastic 1.x ledger may carry a `Tier <value>` line after the spec.md
|
|
233
|
+
# milestone (removed in 2.0, intent 304); a rebuild drops it, and the phantom
|
|
234
|
+
# detector ignores it, so a 1.x store reads clean.
|
|
235
|
+
def self.rebuild_savepoint(intent_dir)
|
|
236
|
+
ordered = [
|
|
237
|
+
File.basename(intent_file(intent_dir)),
|
|
238
|
+
"spec.md", "plan.md", "checklist.md", "outcome.md",
|
|
239
|
+
]
|
|
240
|
+
lines = ordered.flat_map do |basename|
|
|
241
|
+
path = File.join(intent_dir, basename)
|
|
242
|
+
next [] unless stage_file_present?(path)
|
|
243
|
+
stage, milestone = savepoint_milestone(intent_dir, basename)
|
|
244
|
+
next [] unless milestone
|
|
245
|
+
stamp = File.mtime(path).utc.iso8601
|
|
246
|
+
["#{stamp} #{stage} #{milestone}\n"]
|
|
247
|
+
end
|
|
248
|
+
File.write(File.join(intent_dir, SAVEPOINT_FILE), lines.join)
|
|
249
|
+
lines.length
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
# --- Phantom-line detection (intent 134) ------------------------------------
|
|
253
|
+
#
|
|
254
|
+
# A companion to the ledger, not a new writer: pure, disk-only, hermetic (no bridge or
|
|
255
|
+
# session resolution, no writes), matching intent 52's savepoint-decoupling precedent. Under
|
|
256
|
+
# a gate-routing misfire (bug 131) or an out-of-band merge (124a's precedent), a ledger line
|
|
257
|
+
# can go stale or duplicate without the file evidence agreeing. This detects, never repairs;
|
|
258
|
+
# repair is `rebuild_savepoint` (live intents) or the 124a manual Done-bookend recipe
|
|
259
|
+
# (terminal intents, human-granted only).
|
|
260
|
+
|
|
261
|
+
# (stage, milestone) -> basename, for every file-landing milestone this intent_dir could have
|
|
262
|
+
# produced (the intent file plus the four lifecycle artifacts). Reuses savepoint_milestone so
|
|
263
|
+
# the mapping never drifts from the one the writer itself uses.
|
|
264
|
+
def self.savepoint_file_landing_pairs(intent_dir)
|
|
265
|
+
basenames = [File.basename(intent_file(intent_dir)), "spec.md", "plan.md", "checklist.md", "outcome.md"]
|
|
266
|
+
basenames.each_with_object({}) do |basename, map|
|
|
267
|
+
pair = savepoint_milestone(intent_dir, basename)
|
|
268
|
+
map[pair] = basename if pair
|
|
269
|
+
end
|
|
270
|
+
end
|
|
271
|
+
|
|
272
|
+
# A `started` state line's real prerequisite is the PRECEDING stage's artifact, not its own
|
|
273
|
+
# (a `started` line legitimately fires before its own stage's file is real by design). `Exec
|
|
274
|
+
# started` additionally requires plan.md, since Exec cannot start before How produced it too.
|
|
275
|
+
SAVEPOINT_STATE_PREREQUISITES = {
|
|
276
|
+
["How", "started"] => ["spec.md"],
|
|
277
|
+
["Exec", "started"] => ["plan.md", "checklist.md"],
|
|
278
|
+
}.freeze
|
|
279
|
+
|
|
280
|
+
# Raw (stripped) ledger lines whose disk evidence contradicts them, each paired with a short
|
|
281
|
+
# reason: [line, reason]. Three phantom classes (D5):
|
|
282
|
+
# - a file-landing milestone whose file is absent or still a sentinel placeholder;
|
|
283
|
+
# - a duplicate (stage, milestone) pair (the later occurrence is the phantom);
|
|
284
|
+
# - a state line (`How started` / `Exec started`) whose stage prerequisites are absent.
|
|
285
|
+
# A clean ledger, or an absent one, returns [].
|
|
286
|
+
def self.savepoint_phantom_lines(intent_dir)
|
|
287
|
+
path = File.join(intent_dir, SAVEPOINT_FILE)
|
|
288
|
+
return [] unless File.exist?(path)
|
|
289
|
+
|
|
290
|
+
landing = savepoint_file_landing_pairs(intent_dir)
|
|
291
|
+
seen = []
|
|
292
|
+
phantoms = []
|
|
293
|
+
|
|
294
|
+
File.read(path).each_line do |raw|
|
|
295
|
+
line = raw.strip
|
|
296
|
+
next if line.empty?
|
|
297
|
+
parts = line.split(/\s{2,}/)
|
|
298
|
+
next if parts.length < 3
|
|
299
|
+
pair = [parts[1], parts[2]]
|
|
300
|
+
|
|
301
|
+
if seen.include?(pair)
|
|
302
|
+
phantoms << [line, "duplicate (stage, milestone) pair"]
|
|
303
|
+
next
|
|
304
|
+
end
|
|
305
|
+
seen << pair
|
|
306
|
+
|
|
307
|
+
if (basename = landing[pair]) && !stage_file_present?(File.join(intent_dir, basename))
|
|
308
|
+
phantoms << [line, "milestone file absent or still a sentinel placeholder"]
|
|
309
|
+
next
|
|
310
|
+
end
|
|
311
|
+
|
|
312
|
+
prereqs = SAVEPOINT_STATE_PREREQUISITES[pair]
|
|
313
|
+
if prereqs && prereqs.any? { |b| !stage_file_present?(File.join(intent_dir, b)) }
|
|
314
|
+
phantoms << [line, "state line prerequisite absent on disk"]
|
|
315
|
+
end
|
|
316
|
+
end
|
|
317
|
+
|
|
318
|
+
phantoms
|
|
319
|
+
end
|
|
320
|
+
end
|
|
@@ -4,20 +4,21 @@
|
|
|
4
4
|
require "fileutils"
|
|
5
5
|
require_relative "worktree"
|
|
6
6
|
require_relative "bridge"
|
|
7
|
-
require_relative "
|
|
7
|
+
require_relative "savepoint"
|
|
8
8
|
require_relative "intent_validator"
|
|
9
9
|
|
|
10
|
-
# ScaffoldIntent -
|
|
11
|
-
#
|
|
12
|
-
#
|
|
13
|
-
#
|
|
14
|
-
#
|
|
15
|
-
#
|
|
16
|
-
# instead of being guessed at.
|
|
10
|
+
# ScaffoldIntent - the shared, pure helpers behind `scripts/scaffold-intent` (intent 213)
|
|
11
|
+
# and its callers: path and template resolution, section splitting, the intent file's
|
|
12
|
+
# `### Decisions` extraction, and the git-derived diffstat (`build_verification_body`).
|
|
13
|
+
# BackfillIntent (scripts/lib/backfill_intent.rb, intent 308) composes these into the
|
|
14
|
+
# one writer that fills an intent's judgment documents from its record; verify_intent,
|
|
15
|
+
# exec_worktree, and session_git use the repo and base-branch helpers directly.
|
|
17
16
|
#
|
|
18
|
-
#
|
|
19
|
-
#
|
|
20
|
-
#
|
|
17
|
+
# The three per-file subcommands this module once carried were removed in 2.0 (intent
|
|
18
|
+
# 308): `scaffold_spec` and `scaffold_checklist` (removed in 2.0), `scaffold_outcome` with
|
|
19
|
+
# `--force` and `--test-summary` (removed in 2.0). The backfill writer covers their
|
|
20
|
+
# derivable targets, and nothing else called them. Nothing here invents prose: every derived field is a verbatim
|
|
21
|
+
# copy or a mechanical rendering of a committed artifact.
|
|
21
22
|
#
|
|
22
23
|
# Pure and dependency-injected: never calls `exit` or `abort`, never reads `ARGV` or
|
|
23
24
|
# `ENV` directly (only via the ambient `Dir.home` default, matching Bridge/Worktree
|
|
@@ -28,15 +29,6 @@ require_relative "intent_validator"
|
|
|
28
29
|
module ScaffoldIntent
|
|
29
30
|
module_function
|
|
30
31
|
|
|
31
|
-
SPEC_SECTIONS = [
|
|
32
|
-
"## Problem", "## Goals", "## Non-Goals", "## Approach",
|
|
33
|
-
"## Alternatives Considered", "## Decisions",
|
|
34
|
-
"## Acceptance Criteria", "## Open Questions",
|
|
35
|
-
].freeze
|
|
36
|
-
|
|
37
|
-
SETTLED_PLACEHOLDER_COMMENT =
|
|
38
|
-
"<!-- Settled: yes (<reason>) optional, add this line only when the design is settled -->\n"
|
|
39
|
-
|
|
40
32
|
# --- path resolution (pure) --------------------------------------------------
|
|
41
33
|
|
|
42
34
|
def expand(path)
|
|
@@ -84,21 +76,10 @@ module ScaffoldIntent
|
|
|
84
76
|
{ status: :ok, code: 0, message: nil, path: path }
|
|
85
77
|
end
|
|
86
78
|
|
|
87
|
-
def refuse_result(path)
|
|
88
|
-
{ status: :refused, code: 2, path: path,
|
|
89
|
-
message: "#{path} already has real content; pass --force to overwrite it deliberately" }
|
|
90
|
-
end
|
|
91
|
-
|
|
92
79
|
def error_result(message)
|
|
93
80
|
{ status: :error, code: 3, message: message, path: nil }
|
|
94
81
|
end
|
|
95
82
|
|
|
96
|
-
# True iff `target` exists, carries real (non-sentinel) content, and `force` was not
|
|
97
|
-
# passed: the caller must refuse to write and leave the file untouched.
|
|
98
|
-
def refuse_without_force?(target, force)
|
|
99
|
-
File.exist?(target) && Bridge.stage_file_present?(target) && !force
|
|
100
|
-
end
|
|
101
|
-
|
|
102
83
|
# --- generic section helpers (pure) --------------------------------------------
|
|
103
84
|
|
|
104
85
|
# Split `text` into { "## Heading" => body_text } by top-level `## ` headings (never
|
|
@@ -156,125 +137,11 @@ module ScaffoldIntent
|
|
|
156
137
|
[body_lines.join, nil]
|
|
157
138
|
end
|
|
158
139
|
|
|
159
|
-
# --- `## Acceptance Criteria` extraction from spec.md (pure) --------------------
|
|
160
|
-
|
|
161
|
-
# Byte-for-byte body of `## Acceptance Criteria` in `spec_content`. Returns
|
|
162
|
-
# [body, nil] on success, or [nil, message] when the heading is absent or its body
|
|
163
|
-
# holds no `- [ ]` line.
|
|
164
|
-
def extract_acceptance_criteria(spec_content)
|
|
165
|
-
lines = spec_content.lines
|
|
166
|
-
idx = lines.index { |l| l.rstrip == "## Acceptance Criteria" }
|
|
167
|
-
return [nil, "spec.md has no ## Acceptance Criteria section to copy"] if idx.nil?
|
|
168
|
-
|
|
169
|
-
stop = idx + 1
|
|
170
|
-
stop += 1 while stop < lines.length && !lines[stop].start_with?("## ")
|
|
171
|
-
body_lines = strip_blank_edges(lines[(idx + 1)...stop])
|
|
172
|
-
|
|
173
|
-
unless body_lines.any? { |l| l =~ /^\s*- \[ \]/ }
|
|
174
|
-
return [nil, "spec.md's ## Acceptance Criteria has no checklist items (no line matching '- [ ]')"]
|
|
175
|
-
end
|
|
176
|
-
|
|
177
|
-
[body_lines.join, nil]
|
|
178
|
-
end
|
|
179
|
-
|
|
180
140
|
def strip_blank_edges(lines)
|
|
181
141
|
lines = lines.drop_while { |l| l.strip.empty? }
|
|
182
142
|
lines.reverse.drop_while { |l| l.strip.empty? }.reverse
|
|
183
143
|
end
|
|
184
144
|
|
|
185
|
-
# --- spec subcommand ------------------------------------------------------------
|
|
186
|
-
|
|
187
|
-
def scaffold_spec(intent_dir:, force:, templates_dir: nil, home: Dir.home)
|
|
188
|
-
target = File.join(intent_dir, "spec.md")
|
|
189
|
-
return refuse_result(target) if refuse_without_force?(target, force)
|
|
190
|
-
|
|
191
|
-
intent_file = Bridge.intent_file(intent_dir)
|
|
192
|
-
return error_result("the intent file is missing at #{intent_file}") unless File.exist?(intent_file)
|
|
193
|
-
|
|
194
|
-
intent_content = File.read(intent_file)
|
|
195
|
-
fm = IntentValidator.parse_frontmatter_text(intent_content)
|
|
196
|
-
intent_name = fm.is_a?(Hash) ? fm["intent"] : nil
|
|
197
|
-
if Bridge.blank?(intent_name)
|
|
198
|
-
return error_result("the intent file at #{intent_file} has no frontmatter intent name")
|
|
199
|
-
end
|
|
200
|
-
|
|
201
|
-
decisions_body, decisions_err = extract_decisions(intent_content)
|
|
202
|
-
return error_result(decisions_err) if decisions_body.nil?
|
|
203
|
-
|
|
204
|
-
tdir = templates_dir || resolve_templates_dir(home: home)
|
|
205
|
-
return error_result(templates_missing_message(home: home)) if tdir.nil?
|
|
206
|
-
|
|
207
|
-
spec_template_path = File.join(tdir, "spec.md")
|
|
208
|
-
return error_result("spec.md template not found at #{spec_template_path}") unless File.exist?(spec_template_path)
|
|
209
|
-
|
|
210
|
-
written = build_spec_content(intent_name: intent_name, decisions_body: decisions_body,
|
|
211
|
-
template_text: File.read(spec_template_path))
|
|
212
|
-
|
|
213
|
-
FileUtils.mkdir_p(intent_dir)
|
|
214
|
-
File.write(target, written)
|
|
215
|
-
ok_result(target)
|
|
216
|
-
end
|
|
217
|
-
|
|
218
|
-
def build_spec_content(intent_name:, decisions_body:, template_text:)
|
|
219
|
-
template_sections = sections_from(template_text)
|
|
220
|
-
|
|
221
|
-
out = []
|
|
222
|
-
out << SpecHeader.render(tier: nil, settled_reason: nil)
|
|
223
|
-
out << SETTLED_PLACEHOLDER_COMMENT
|
|
224
|
-
out << "\n"
|
|
225
|
-
out << "# Spec: #{intent_name}\n"
|
|
226
|
-
out << "\n"
|
|
227
|
-
|
|
228
|
-
SPEC_SECTIONS.each do |heading|
|
|
229
|
-
out << "#{heading}\n"
|
|
230
|
-
if heading == "## Decisions"
|
|
231
|
-
out << decisions_body
|
|
232
|
-
out << "\n"
|
|
233
|
-
else
|
|
234
|
-
out << (template_sections[heading] || "")
|
|
235
|
-
end
|
|
236
|
-
end
|
|
237
|
-
|
|
238
|
-
out.join
|
|
239
|
-
end
|
|
240
|
-
|
|
241
|
-
# --- checklist subcommand --------------------------------------------------------
|
|
242
|
-
|
|
243
|
-
def scaffold_checklist(intent_dir:, force:, templates_dir: nil, home: Dir.home)
|
|
244
|
-
target = File.join(intent_dir, "checklist.md")
|
|
245
|
-
return refuse_result(target) if refuse_without_force?(target, force)
|
|
246
|
-
|
|
247
|
-
spec_path = File.join(intent_dir, "spec.md")
|
|
248
|
-
unless File.exist?(spec_path) && Bridge.stage_file_present?(spec_path)
|
|
249
|
-
return error_result("spec.md is missing or still the scaffold placeholder at #{spec_path}")
|
|
250
|
-
end
|
|
251
|
-
|
|
252
|
-
ac_body, ac_err = extract_acceptance_criteria(File.read(spec_path))
|
|
253
|
-
return error_result(ac_err) if ac_body.nil?
|
|
254
|
-
|
|
255
|
-
intent_file = Bridge.intent_file(intent_dir)
|
|
256
|
-
fm = IntentValidator.parse_frontmatter(intent_file)
|
|
257
|
-
intent_name = fm.is_a?(Hash) ? fm["intent"] : nil
|
|
258
|
-
if Bridge.blank?(intent_name)
|
|
259
|
-
return error_result("the intent file at #{intent_file} has no frontmatter intent name")
|
|
260
|
-
end
|
|
261
|
-
|
|
262
|
-
tdir = templates_dir || resolve_templates_dir(home: home)
|
|
263
|
-
return error_result(templates_missing_message(home: home)) if tdir.nil?
|
|
264
|
-
|
|
265
|
-
checklist_template_path = File.join(tdir, "checklist.md")
|
|
266
|
-
unless File.exist?(checklist_template_path)
|
|
267
|
-
return error_result("checklist.md template not found at #{checklist_template_path}")
|
|
268
|
-
end
|
|
269
|
-
|
|
270
|
-
template_text = File.read(checklist_template_path).sub("{{INTENT_NAME}}", intent_name)
|
|
271
|
-
written = replace_section_body(template_text, "## In Progress", [ac_body, "\n"])
|
|
272
|
-
|
|
273
|
-
FileUtils.mkdir_p(intent_dir)
|
|
274
|
-
File.write(target, written)
|
|
275
|
-
ok_result(target)
|
|
276
|
-
end
|
|
277
|
-
|
|
278
145
|
# --- repo / base-branch resolution (shared with ACTION_3) -----------------------
|
|
279
146
|
|
|
280
147
|
# The provisioned code worktree for this intent when it exists on disk, else the git
|
|
@@ -318,40 +185,7 @@ module ScaffoldIntent
|
|
|
318
185
|
[res.stdout.to_s, nil]
|
|
319
186
|
end
|
|
320
187
|
|
|
321
|
-
# ---
|
|
322
|
-
|
|
323
|
-
def scaffold_outcome(intent_dir:, force:, store:, id:, test_summary: nil,
|
|
324
|
-
home: Dir.home, runner: Worktree::ShellRunner.new, templates_dir: nil)
|
|
325
|
-
target = File.join(intent_dir, "outcome.md")
|
|
326
|
-
return refuse_result(target) if refuse_without_force?(target, force)
|
|
327
|
-
|
|
328
|
-
intent_file = Bridge.intent_file(intent_dir)
|
|
329
|
-
return error_result("the intent file is missing at #{intent_file}") unless File.exist?(intent_file)
|
|
330
|
-
|
|
331
|
-
fm = IntentValidator.parse_frontmatter(intent_file)
|
|
332
|
-
intent_name = fm.is_a?(Hash) ? fm["intent"] : nil
|
|
333
|
-
if Bridge.blank?(intent_name)
|
|
334
|
-
return error_result("the intent file at #{intent_file} has no frontmatter intent name")
|
|
335
|
-
end
|
|
336
|
-
|
|
337
|
-
tdir = templates_dir || resolve_templates_dir(home: home)
|
|
338
|
-
return error_result(templates_missing_message(home: home)) if tdir.nil?
|
|
339
|
-
|
|
340
|
-
outcome_template_path = File.join(tdir, "outcome.md")
|
|
341
|
-
unless File.exist?(outcome_template_path)
|
|
342
|
-
return error_result("outcome.md template not found at #{outcome_template_path}")
|
|
343
|
-
end
|
|
344
|
-
|
|
345
|
-
verification_body = build_verification_body(store: store, id: id, intent_dir: intent_dir,
|
|
346
|
-
home: home, runner: runner, test_summary: test_summary)
|
|
347
|
-
|
|
348
|
-
content = File.read(outcome_template_path).sub("# Outcome: <intent name>", "# Outcome: #{intent_name}")
|
|
349
|
-
written = replace_section_body(content, "## Verification", [verification_body, "\n"])
|
|
350
|
-
|
|
351
|
-
FileUtils.mkdir_p(intent_dir)
|
|
352
|
-
File.write(target, written)
|
|
353
|
-
ok_result(target)
|
|
354
|
-
end
|
|
188
|
+
# --- verification body (diffstat plus an optional test summary) ----------------------
|
|
355
189
|
|
|
356
190
|
def build_verification_body(store:, id:, intent_dir:, home:, runner:, test_summary:)
|
|
357
191
|
repo = resolve_repo_dir(store: store, id: id, intent_dir: intent_dir, home: home, runner: runner)
|