@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,497 @@
|
|
|
1
|
+
# encoding: UTF-8
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require "date"
|
|
5
|
+
require "fileutils"
|
|
6
|
+
require_relative "store_provisioning"
|
|
7
|
+
|
|
8
|
+
# SessionLedger - the pure library behind the session intent day ledger (intent
|
|
9
|
+
# 297). It owns identity derivation (day id, short session id, project slug),
|
|
10
|
+
# the day and session path layout under a store's `.sessions/` and `.tmp/`
|
|
11
|
+
# directories, the byte-exact checklist and savepoint line formats, and the
|
|
12
|
+
# locked reads and writes that let many sessions share one day's files safely.
|
|
13
|
+
#
|
|
14
|
+
# Every method takes every path as an argument. This file reads no environment
|
|
15
|
+
# variable and runs no dynamically constructed code: only its CLIs
|
|
16
|
+
# (`scripts/new-intent --tmp`, `scripts/append-ledger`) read the environment
|
|
17
|
+
# and pass what they read in.
|
|
18
|
+
module SessionLedger
|
|
19
|
+
# Raised by #set_state when an in-place edit cannot take an exclusive lock
|
|
20
|
+
# (a filesystem without flock support). An append stays safe unlocked, since
|
|
21
|
+
# a single O_APPEND write lands whole; an in-place byte edit does not, so it
|
|
22
|
+
# refuses rather than risk a torn read-modify-write. The CLI maps this to
|
|
23
|
+
# exit 3.
|
|
24
|
+
class LockUnavailableError < StandardError; end
|
|
25
|
+
|
|
26
|
+
module_function
|
|
27
|
+
|
|
28
|
+
# --- Constants (spec D13), byte exact ------------------------------------
|
|
29
|
+
|
|
30
|
+
SESSIONS_DIR = ".sessions"
|
|
31
|
+
TMP_DIR = ".tmp"
|
|
32
|
+
DAY_ID = /\A\d{8}\z/
|
|
33
|
+
EVENTS = %w[Item Done Note]
|
|
34
|
+
STATES = { pending: "~", open: " ", done: "x", moved: ">", dropped: "-", promoted: "^" }.freeze
|
|
35
|
+
|
|
36
|
+
# Project slugs and session tags are restricted to this character class
|
|
37
|
+
# (spec D4). Used both to filter a slug read out of projects.yml
|
|
38
|
+
# (#project_slug) and, by append-ledger, to validate an explicit --project.
|
|
39
|
+
SLUG_RE = /\A[a-z0-9-]+\z/.freeze
|
|
40
|
+
|
|
41
|
+
# Default lock strategy for #append_line and #set_state: a real flock call
|
|
42
|
+
# on the handle. Tests inject a replacement lambda here (never a global or
|
|
43
|
+
# an environment-variable flag, per the library's no-environment-read rule)
|
|
44
|
+
# to simulate a SystemCallError from a filesystem without flock support,
|
|
45
|
+
# proving the unlocked-append fallback and the LockUnavailableError -> exit
|
|
46
|
+
# 3 mapping hermetically, with no need for a real flock-less filesystem.
|
|
47
|
+
DEFAULT_FLOCK = ->(handle, mode) { handle.flock(mode) }
|
|
48
|
+
private_constant :DEFAULT_FLOCK
|
|
49
|
+
|
|
50
|
+
# The first append to checklist.md writes this header, then a blank line,
|
|
51
|
+
# before any checklist content (spec D9). savepoint.md never gets a header.
|
|
52
|
+
def checklist_header(day)
|
|
53
|
+
"# Checklist: session ledger #{day}\n\n"
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# --- Identity -------------------------------------------------------------
|
|
57
|
+
|
|
58
|
+
# The day id is the caller's local wall-clock date. Never call `.utc` here:
|
|
59
|
+
# the owner's day is his wall clock, and a UTC id would already read
|
|
60
|
+
# tomorrow in the evening locally. Instants inside ledger lines stay UTC,
|
|
61
|
+
# which is a concern of #savepoint_line, not this method.
|
|
62
|
+
def day_id(now = Time.now)
|
|
63
|
+
now.strftime("%Y%m%d")
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# True iff `id` is eight digits that also parse as a real calendar date, so
|
|
67
|
+
# a shape match like "20261340" (month 13) is still rejected.
|
|
68
|
+
def valid_day_id?(id)
|
|
69
|
+
s = id.to_s
|
|
70
|
+
return false unless s.match?(DAY_ID)
|
|
71
|
+
|
|
72
|
+
begin
|
|
73
|
+
Date.strptime(s, "%Y%m%d")
|
|
74
|
+
true
|
|
75
|
+
rescue Date::Error, ArgumentError
|
|
76
|
+
false
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# The first eight `[a-z0-9]` characters of the caller's session id, after
|
|
81
|
+
# downcasing. Source order: `explicit`, then `env_id`, then the literal
|
|
82
|
+
# `"local"`. Both candidates are arguments; only a CLI reads the environment.
|
|
83
|
+
def short_session_id(explicit = nil, env_id = nil)
|
|
84
|
+
candidate = [explicit, env_id].find { |c| c.is_a?(String) && !c.strip.empty? }
|
|
85
|
+
candidate ||= "local"
|
|
86
|
+
|
|
87
|
+
cleaned = candidate.downcase.gsub(/[^a-z0-9]/, "")
|
|
88
|
+
cleaned = "local" if cleaned.empty?
|
|
89
|
+
cleaned[0, 8]
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# The slug of the registered `projects.yml` path that is the longest match
|
|
93
|
+
# for `cwd` (equal to, or nested under, that path). Falls back to the
|
|
94
|
+
# literal `"global"` when nothing matches: an invented slug (from a
|
|
95
|
+
# directory basename) would name no real store. A candidate slug that does
|
|
96
|
+
# not match SLUG_RE (spec D4: `[a-z0-9-]` only) is skipped rather than
|
|
97
|
+
# returned, since it would corrupt a ledger line that embeds it verbatim;
|
|
98
|
+
# the "global" fallback is always safe by construction.
|
|
99
|
+
def project_slug(cwd, plastic_home:)
|
|
100
|
+
expanded_cwd = File.expand_path(cwd)
|
|
101
|
+
projects = StoreProvisioning.load_projects(plastic_home)
|
|
102
|
+
return "global" unless projects.is_a?(Hash)
|
|
103
|
+
|
|
104
|
+
matches = projects.filter_map do |slug, info|
|
|
105
|
+
next unless info.is_a?(Hash)
|
|
106
|
+
next unless slug.is_a?(String) && SLUG_RE.match?(slug)
|
|
107
|
+
|
|
108
|
+
path = info["path"]
|
|
109
|
+
next unless path
|
|
110
|
+
|
|
111
|
+
root = File.expand_path(path)
|
|
112
|
+
next unless expanded_cwd == root || expanded_cwd.start_with?(root + File::SEPARATOR)
|
|
113
|
+
|
|
114
|
+
[root.length, slug]
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
best = matches.max_by { |(length, _slug)| length }
|
|
118
|
+
best ? best[1] : "global"
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# --- Day paths (no I/O) ----------------------------------------------------
|
|
122
|
+
|
|
123
|
+
def sessions_root(store)
|
|
124
|
+
File.join(store, SESSIONS_DIR)
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def day_dir(store, day)
|
|
128
|
+
File.join(sessions_root(store), day)
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def day_file(store, day)
|
|
132
|
+
File.join(day_dir(store, day), "#{day}.md")
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def checklist_path(store, day)
|
|
136
|
+
File.join(day_dir(store, day), "checklist.md")
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def savepoint_path(store, day)
|
|
140
|
+
File.join(day_dir(store, day), "savepoint.md")
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
# --- Session paths (no I/O except #ensure_tmp_root) ------------------------
|
|
144
|
+
|
|
145
|
+
def tmp_root(store)
|
|
146
|
+
File.join(store, TMP_DIR)
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def session_tmp_dir(store, session_id)
|
|
150
|
+
File.join(tmp_root(store), session_id)
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def pointer_path(store, session_id)
|
|
154
|
+
File.join(session_tmp_dir(store, session_id), "current")
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def heartbeat_path(store, session_id)
|
|
158
|
+
File.join(session_tmp_dir(store, session_id), "heartbeat")
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
# Create `.tmp/` plus a `.gitignore` holding exactly `*`, if that file does
|
|
162
|
+
# not already exist. The store is a local git repo that auto-commits
|
|
163
|
+
# `add -A`, so without this ignore file every heartbeat write would enter
|
|
164
|
+
# git history. Intent 298 calls this before writing a pointer or heartbeat;
|
|
165
|
+
# this intent only defines it. Returns the tmp root path.
|
|
166
|
+
def ensure_tmp_root(store)
|
|
167
|
+
root = tmp_root(store)
|
|
168
|
+
FileUtils.mkdir_p(root)
|
|
169
|
+
|
|
170
|
+
gitignore = File.join(root, ".gitignore")
|
|
171
|
+
File.write(gitignore, "*\n") unless File.exist?(gitignore)
|
|
172
|
+
|
|
173
|
+
root
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
# --- Lines ------------------------------------------------------------------
|
|
177
|
+
|
|
178
|
+
# Collapse every run of whitespace (newlines included) to one space, strip,
|
|
179
|
+
# then cap the result at 200 characters total, ending in "..." when the
|
|
180
|
+
# input was longer. Returns an empty string when nothing survives; the
|
|
181
|
+
# caller decides whether that is a usage error.
|
|
182
|
+
def sanitize_summary(text)
|
|
183
|
+
collapsed = text.to_s.gsub(/\s+/, " ").strip
|
|
184
|
+
return collapsed if collapsed.length <= 200
|
|
185
|
+
|
|
186
|
+
"#{collapsed[0, 197]}..."
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
# One LF-terminated checklist line, byte exact per spec D5. The state
|
|
190
|
+
# marker is fixed width across all three states, which is what lets a later
|
|
191
|
+
# promote or tick be a one-byte write at a known offset.
|
|
192
|
+
def checklist_line(state, session, project, summary)
|
|
193
|
+
marker = STATES.fetch(state) { raise ArgumentError, "unknown checklist state: #{state.inspect}" }
|
|
194
|
+
"- [#{marker}] [#{session}] [#{project}] #{summary}\n"
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
# One LF-terminated savepoint line, byte exact per spec D5. Separators are
|
|
198
|
+
# two spaces, so `split(/\s{2,}/)` yields exactly three parts. The
|
|
199
|
+
# timestamp is UTC ISO 8601, matching every other savepoint line in the
|
|
200
|
+
# store; the day id stays local wall clock, an intentional asymmetry.
|
|
201
|
+
def savepoint_line(event, session, project, summary, now:)
|
|
202
|
+
raise ArgumentError, "unknown savepoint event: #{event.inspect}" unless EVENTS.include?(event)
|
|
203
|
+
|
|
204
|
+
timestamp = now.utc.strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
205
|
+
"#{timestamp} #{event} [#{session}] [#{project}] #{summary}\n"
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
# The inverse of #checklist_line: parse a checklist line positionally (the
|
|
209
|
+
# state marker, the first bracket group, the second bracket group, then the
|
|
210
|
+
# rest as the summary), so a bracket typed inside a summary is never read
|
|
211
|
+
# as a tag. Returns nil when the line does not match.
|
|
212
|
+
CHECKLIST_LINE_RE = /\A- \[(.)\] \[([^\]]*)\] \[([^\]]*)\] (.*)\z/m.freeze
|
|
213
|
+
private_constant :CHECKLIST_LINE_RE
|
|
214
|
+
|
|
215
|
+
def parse_checklist_line(line)
|
|
216
|
+
# #scrub replaces any invalid byte with U+FFFD so a stray non-UTF-8 byte
|
|
217
|
+
# anywhere in checklist.md never raises ArgumentError out of the regex
|
|
218
|
+
# match; it only ever affects that one line's parsed summary. Callers
|
|
219
|
+
# that need byte-exact offsets (namely #set_state) must measure against
|
|
220
|
+
# the UNSCRUBBED line, since #scrub can change a line's bytesize.
|
|
221
|
+
match = CHECKLIST_LINE_RE.match(line.to_s.chomp.scrub)
|
|
222
|
+
return nil unless match
|
|
223
|
+
|
|
224
|
+
marker, session, project, summary = match.captures
|
|
225
|
+
state = STATES.key(marker)
|
|
226
|
+
return nil unless state
|
|
227
|
+
|
|
228
|
+
{ state: state, session: session, project: project, summary: summary }
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
# --- Token rendering (internal) --------------------------------------------
|
|
232
|
+
|
|
233
|
+
# Block-form gsub, mirroring scripts/new-intent#render_tokens: a String
|
|
234
|
+
# replacement argument reinterprets backslash sequences, so the block form
|
|
235
|
+
# substitutes each value literally.
|
|
236
|
+
def render_tokens(text, tokens)
|
|
237
|
+
tokens.reduce(text) { |acc, (key, value)| acc.gsub("{{#{key}}}") { value.to_s } }
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
# --- Locked writes and reads (spec D8) --------------------------------------
|
|
241
|
+
|
|
242
|
+
# Append `line` to `path`, taking an exclusive lock on the target file
|
|
243
|
+
# itself (never a sibling lock file, never via a rename). Opens
|
|
244
|
+
# WRONLY | APPEND | CREAT so a concurrent append always lands at the
|
|
245
|
+
# current end of file. Once the lock is held, writes `header` first only
|
|
246
|
+
# when the file is still size zero (the size check happens under the lock
|
|
247
|
+
# on purpose: O_CREAT without O_EXCL hands every racer the same inode, so
|
|
248
|
+
# the lock serializes them and exactly one racer sees size zero). Then
|
|
249
|
+
# writes the full line in one call. Rescues SystemCallError from flock
|
|
250
|
+
# only: on a filesystem without flock support, the append proceeds
|
|
251
|
+
# unlocked, since a single O_APPEND write still lands whole there. Always
|
|
252
|
+
# returns true.
|
|
253
|
+
def append_line(path, line, header: nil, flock: DEFAULT_FLOCK)
|
|
254
|
+
handle = File.open(path, File::WRONLY | File::APPEND | File::CREAT, 0o644)
|
|
255
|
+
begin
|
|
256
|
+
begin
|
|
257
|
+
flock.call(handle, File::LOCK_EX)
|
|
258
|
+
rescue SystemCallError
|
|
259
|
+
nil
|
|
260
|
+
end
|
|
261
|
+
|
|
262
|
+
handle.write(header) if header && handle.size.zero?
|
|
263
|
+
handle.write(line)
|
|
264
|
+
# Flush explicitly before unlocking. MRI happens to flush a writable
|
|
265
|
+
# handle's buffer as a side effect inside rb_file_flock, but that is
|
|
266
|
+
# undocumented behavior, so flush on purpose rather than depend on it.
|
|
267
|
+
handle.flush
|
|
268
|
+
|
|
269
|
+
begin
|
|
270
|
+
handle.flock(File::LOCK_UN)
|
|
271
|
+
rescue SystemCallError
|
|
272
|
+
nil
|
|
273
|
+
end
|
|
274
|
+
ensure
|
|
275
|
+
handle.close
|
|
276
|
+
end
|
|
277
|
+
true
|
|
278
|
+
end
|
|
279
|
+
|
|
280
|
+
# Flip the newest line (last in file order) whose session tag equals
|
|
281
|
+
# `session` and whose state equals `from` to `to`, narrowed to lines whose
|
|
282
|
+
# summary contains `match` when given (spec D7). The lock is taken on the
|
|
283
|
+
# target file itself. Once held, the file is scanned by byte offset, and
|
|
284
|
+
# exactly one byte (the state marker, at the matched line's start offset
|
|
285
|
+
# plus 3, past the "- [" prefix) is overwritten with `IO#pwrite`, so every
|
|
286
|
+
# other byte in the file stays identical: this is exactly why the pending
|
|
287
|
+
# marker is `[~]`, a fixed width shared with `[ ]` and `[x]`, rather than a
|
|
288
|
+
# bare tilde.
|
|
289
|
+
#
|
|
290
|
+
# Identifying the target line and flipping it happen inside the SAME
|
|
291
|
+
# LOCK_EX hold, on purpose: an earlier version identified the target under
|
|
292
|
+
# a released LOCK_SH, in a separate call, then re-identified and flipped it
|
|
293
|
+
# under a fresh LOCK_EX. Two concurrent promoters could both read the same
|
|
294
|
+
# "newest pending" line before either flipped anything, each then flip a
|
|
295
|
+
# DIFFERENT line under their own (correctly serialized) LOCK_EX, and the
|
|
296
|
+
# caller's earlier lookup would go stale, naming the wrong line in a
|
|
297
|
+
# --savepoint entry. Returning the flipped line's own summary from inside
|
|
298
|
+
# this lock is what makes that identify-and-flip atomic, so a caller never
|
|
299
|
+
# needs a second, separately-locked read to learn what it just changed.
|
|
300
|
+
#
|
|
301
|
+
# Returns the flipped line's summary (a String) when a byte changed, or nil
|
|
302
|
+
# (writing nothing) when the file does not exist or nothing matches. Raises
|
|
303
|
+
# LockUnavailableError, refusing to write at all, when the flock cannot be
|
|
304
|
+
# taken (filesystem without flock support): unlike an append, an in-place
|
|
305
|
+
# edit cannot fall back to unlocked, since a racing writer could tear the
|
|
306
|
+
# read-modify-write. `flock:` is a test seam (see DEFAULT_FLOCK); production
|
|
307
|
+
# callers never pass it.
|
|
308
|
+
def set_state(path, from:, to:, session:, match: nil, flock: DEFAULT_FLOCK)
|
|
309
|
+
return nil unless File.exist?(path)
|
|
310
|
+
|
|
311
|
+
STATES.fetch(from)
|
|
312
|
+
to_marker = STATES.fetch(to)
|
|
313
|
+
|
|
314
|
+
handle = File.open(path, File::RDWR)
|
|
315
|
+
locked = true
|
|
316
|
+
begin
|
|
317
|
+
flock.call(handle, File::LOCK_EX)
|
|
318
|
+
rescue SystemCallError
|
|
319
|
+
locked = false
|
|
320
|
+
end
|
|
321
|
+
|
|
322
|
+
unless locked
|
|
323
|
+
handle.close
|
|
324
|
+
raise LockUnavailableError, "cannot take an exclusive lock on #{path}"
|
|
325
|
+
end
|
|
326
|
+
|
|
327
|
+
begin
|
|
328
|
+
content = handle.read
|
|
329
|
+
target_offset = nil
|
|
330
|
+
target_summary = nil
|
|
331
|
+
offset = 0
|
|
332
|
+
|
|
333
|
+
content.each_line do |raw_line|
|
|
334
|
+
parsed = parse_checklist_line(raw_line)
|
|
335
|
+
if parsed && (session.nil? || parsed[:session] == session) && parsed[:state] == from &&
|
|
336
|
+
(match.nil? || parsed[:summary].include?(match))
|
|
337
|
+
target_offset = offset + 3 # past the "- [" prefix
|
|
338
|
+
target_summary = parsed[:summary]
|
|
339
|
+
end
|
|
340
|
+
# Accumulate over the UNSCRUBBED raw_line, never the copy
|
|
341
|
+
# #parse_checklist_line scrubs internally for matching: #scrub can
|
|
342
|
+
# change a line's bytesize, and pwrite below must land at the true
|
|
343
|
+
# on-disk byte offset.
|
|
344
|
+
offset += raw_line.bytesize
|
|
345
|
+
end
|
|
346
|
+
|
|
347
|
+
if target_offset.nil?
|
|
348
|
+
nil
|
|
349
|
+
else
|
|
350
|
+
handle.pwrite(to_marker, target_offset)
|
|
351
|
+
handle.flush
|
|
352
|
+
target_summary
|
|
353
|
+
end
|
|
354
|
+
ensure
|
|
355
|
+
begin
|
|
356
|
+
handle.flock(File::LOCK_UN)
|
|
357
|
+
rescue SystemCallError
|
|
358
|
+
nil
|
|
359
|
+
end
|
|
360
|
+
handle.close
|
|
361
|
+
end
|
|
362
|
+
end
|
|
363
|
+
|
|
364
|
+
# Flip every checklist line in state `from` to state `to` for one session
|
|
365
|
+
# (or any session when `session` is nil), narrowed to summaries containing
|
|
366
|
+
# `match` when given (intent 301). Same lock discipline as #set_state: one
|
|
367
|
+
# LOCK_EX for the whole read-scan-write, one pwrite of one byte per
|
|
368
|
+
# flipped line at its measured offset, never a whole-file rewrite, and a
|
|
369
|
+
# LockUnavailableError rather than any unlocked write. Returns the count of
|
|
370
|
+
# lines flipped, 0 when the file is absent or nothing matches.
|
|
371
|
+
def flip_all(path, from:, to:, session: nil, match: nil, flock: DEFAULT_FLOCK)
|
|
372
|
+
return 0 unless File.exist?(path)
|
|
373
|
+
|
|
374
|
+
STATES.fetch(from)
|
|
375
|
+
to_marker = STATES.fetch(to)
|
|
376
|
+
handle = File.open(path, File::RDWR)
|
|
377
|
+
locked = true
|
|
378
|
+
begin
|
|
379
|
+
flock.call(handle, File::LOCK_EX)
|
|
380
|
+
rescue SystemCallError
|
|
381
|
+
locked = false
|
|
382
|
+
end
|
|
383
|
+
unless locked
|
|
384
|
+
handle.close
|
|
385
|
+
raise LockUnavailableError, "cannot take an exclusive lock on #{path}"
|
|
386
|
+
end
|
|
387
|
+
|
|
388
|
+
begin
|
|
389
|
+
content = handle.read
|
|
390
|
+
offsets = []
|
|
391
|
+
offset = 0
|
|
392
|
+
content.each_line do |raw_line|
|
|
393
|
+
parsed = parse_checklist_line(raw_line)
|
|
394
|
+
if parsed && (session.nil? || parsed[:session] == session) && parsed[:state] == from &&
|
|
395
|
+
(match.nil? || parsed[:summary].include?(match))
|
|
396
|
+
offsets << offset + 3
|
|
397
|
+
end
|
|
398
|
+
offset += raw_line.bytesize
|
|
399
|
+
end
|
|
400
|
+
offsets.each { |o| handle.pwrite(to_marker, o) }
|
|
401
|
+
handle.flush unless offsets.empty?
|
|
402
|
+
offsets.size
|
|
403
|
+
ensure
|
|
404
|
+
begin
|
|
405
|
+
handle.flock(File::LOCK_UN)
|
|
406
|
+
rescue SystemCallError
|
|
407
|
+
nil
|
|
408
|
+
end
|
|
409
|
+
handle.close
|
|
410
|
+
end
|
|
411
|
+
end
|
|
412
|
+
|
|
413
|
+
# Read `path` under a shared lock, returning its content. Returns an empty
|
|
414
|
+
# string when the file does not exist. Rescues SystemCallError from flock
|
|
415
|
+
# and reads anyway.
|
|
416
|
+
def read_locked(path)
|
|
417
|
+
return "" unless File.exist?(path)
|
|
418
|
+
|
|
419
|
+
handle = File.open(path, File::RDONLY)
|
|
420
|
+
begin
|
|
421
|
+
begin
|
|
422
|
+
handle.flock(File::LOCK_SH)
|
|
423
|
+
rescue SystemCallError
|
|
424
|
+
nil
|
|
425
|
+
end
|
|
426
|
+
handle.read
|
|
427
|
+
ensure
|
|
428
|
+
begin
|
|
429
|
+
handle.flock(File::LOCK_UN)
|
|
430
|
+
rescue SystemCallError
|
|
431
|
+
nil
|
|
432
|
+
end
|
|
433
|
+
handle.close
|
|
434
|
+
end
|
|
435
|
+
end
|
|
436
|
+
|
|
437
|
+
# --- The day scaffold (spec D12) --------------------------------------------
|
|
438
|
+
|
|
439
|
+
# The one and only scaffold implementation. `new-intent --tmp` is its CLI,
|
|
440
|
+
# and `append-ledger` calls it directly on EVERY invocation (cheap and
|
|
441
|
+
# idempotent, so there is no cheaper-but-wrong guard to key on instead), so
|
|
442
|
+
# a capture that crosses midnight never fails and never needs a second
|
|
443
|
+
# process. Creates exactly `<day>/<day>.md`: no checklist.md, no
|
|
444
|
+
# savepoint.md, no actions/, no resources/. `checklist.md` and
|
|
445
|
+
# `savepoint.md` come into existence on first append, written by
|
|
446
|
+
# append-ledger under the lock, which is why this method never touches
|
|
447
|
+
# them.
|
|
448
|
+
#
|
|
449
|
+
# Create versus join is decided by opening the day file with
|
|
450
|
+
# File::CREAT | File::EXCL: the winner renders the template and returns
|
|
451
|
+
# created: true; every loser, including a repair of a crashed
|
|
452
|
+
# mid-scaffold with no md file yet, returns created: false without
|
|
453
|
+
# changing a byte. DATE (the day's own calendar date, used in the `intent:`
|
|
454
|
+
# line) is derived from `day`, not from `now`, so an explicit --day renders
|
|
455
|
+
# its own date rather than today's; `now:` instead sources CREATED, the
|
|
456
|
+
# `created:` frontmatter field, since that field records when the scaffold
|
|
457
|
+
# FILE was actually written, which can differ from the day it is for (a
|
|
458
|
+
# repair or a midnight-crossing capture can scaffold a past day's file
|
|
459
|
+
# today). Defaults to Time.now; tests inject a fixed `now:` to make the
|
|
460
|
+
# scaffold's `created:` value deterministic.
|
|
461
|
+
#
|
|
462
|
+
# If rendering fails partway (a missing or relocated templates dir, a bad
|
|
463
|
+
# day), the file this call just created is unlinked before the error
|
|
464
|
+
# re-raises, so no zero-byte or partial <day>.md is left behind to wedge
|
|
465
|
+
# every later #open_day call onto the Errno::EEXIST "already exists"
|
|
466
|
+
# branch with no file to repair.
|
|
467
|
+
def open_day(store:, day:, templates:, author:, now: Time.now)
|
|
468
|
+
dir = day_dir(store, day)
|
|
469
|
+
FileUtils.mkdir_p(dir)
|
|
470
|
+
|
|
471
|
+
file = day_file(store, day)
|
|
472
|
+
handle = begin
|
|
473
|
+
File.open(file, File::WRONLY | File::CREAT | File::EXCL, 0o644)
|
|
474
|
+
rescue Errno::EEXIST
|
|
475
|
+
nil
|
|
476
|
+
end
|
|
477
|
+
|
|
478
|
+
return { dir: dir, created: false } unless handle
|
|
479
|
+
|
|
480
|
+
begin
|
|
481
|
+
begin
|
|
482
|
+
date = Date.strptime(day, "%Y%m%d").iso8601
|
|
483
|
+
created = now.strftime("%Y-%m-%d")
|
|
484
|
+
template = File.read(File.join(templates, "session-intent.md"))
|
|
485
|
+
rendered = render_tokens(template, "DAY" => day, "DATE" => date, "CREATED" => created, "AUTHOR" => author)
|
|
486
|
+
handle.write(rendered)
|
|
487
|
+
rescue StandardError
|
|
488
|
+
File.delete(file) if File.exist?(file)
|
|
489
|
+
raise
|
|
490
|
+
end
|
|
491
|
+
ensure
|
|
492
|
+
handle.close unless handle.closed?
|
|
493
|
+
end
|
|
494
|
+
|
|
495
|
+
{ dir: dir, created: true }
|
|
496
|
+
end
|
|
497
|
+
end
|
|
@@ -33,7 +33,7 @@ module StoreDiscovery
|
|
|
33
33
|
# INDEX.md path. Sorted by slug (global first) for deterministic output.
|
|
34
34
|
#
|
|
35
35
|
# `missing` lists every projects.yml slug with no `store/` directory on disk: legal
|
|
36
|
-
# (plastic-
|
|
36
|
+
# (the plastic-doctor provisioning section exists for exactly this state), reported so callers never
|
|
37
37
|
# mistake it for a store with zero intents.
|
|
38
38
|
def discover(plastic_home)
|
|
39
39
|
stores = []
|
|
@@ -14,7 +14,7 @@ require "yaml"
|
|
|
14
14
|
# `project.yml` (from templates/project.yml). Re-running never clobbers existing
|
|
15
15
|
# files. The logic was migrated here from the orphaned
|
|
16
16
|
# `InstallerCore#bootstrap_project_store` so there is one definition that the
|
|
17
|
-
# `provision-project-store` CLI, the `plastic-
|
|
17
|
+
# `provision-project-store` CLI, the `plastic-doctor` skill's provisioning section, the
|
|
18
18
|
# doctor fix hint, and the project skills all consult.
|
|
19
19
|
#
|
|
20
20
|
# Pure filesystem and dependency-injected: `provision` accepts an injectable
|
package/scripts/maintenance-run
CHANGED
|
@@ -37,7 +37,7 @@ require "time"
|
|
|
37
37
|
require_relative "lib/store_discovery"
|
|
38
38
|
require_relative "lib/lock"
|
|
39
39
|
require_relative "lib/maintenance_git"
|
|
40
|
-
require_relative "lib/
|
|
40
|
+
require_relative "lib/savepoint"
|
|
41
41
|
require_relative "lib/revisions_writer"
|
|
42
42
|
require_relative "doctor" # safe: doctor.rb's CLI is behind $PROGRAM_NAME == __FILE__
|
|
43
43
|
|
|
@@ -233,7 +233,7 @@ def run_rebuild_savepoint(home, intent, store, apply)
|
|
|
233
233
|
check_not_fresh!(dir, intent)
|
|
234
234
|
|
|
235
235
|
outcome_path = File.join(dir, "outcome.md")
|
|
236
|
-
unless
|
|
236
|
+
unless Savepoint.stage_file_present?(outcome_path)
|
|
237
237
|
abort_loud("intent #{intent}'s outcome.md is missing or a placeholder; the 124a recipe " \
|
|
238
238
|
"requires a real disposition to echo, and 219 D6 forbids ever inventing one", 1)
|
|
239
239
|
end
|
|
@@ -244,7 +244,7 @@ def run_rebuild_savepoint(home, intent, store, apply)
|
|
|
244
244
|
|
|
245
245
|
unless apply
|
|
246
246
|
puts "maintenance-run: DRY RUN, would reconstruct savepoint.md for #{intent} " \
|
|
247
|
-
"(
|
|
247
|
+
"(Savepoint.rebuild_savepoint + Done #{disposition} echo)"
|
|
248
248
|
exit 0
|
|
249
249
|
end
|
|
250
250
|
|
|
@@ -253,15 +253,15 @@ def run_rebuild_savepoint(home, intent, store, apply)
|
|
|
253
253
|
repo_dir: home, branch_name: "maintenance/rebuild-savepoint-#{intent}-#{stamp}",
|
|
254
254
|
commit_message: "chore: maintenance - rebuild-savepoint #{intent}"
|
|
255
255
|
) do
|
|
256
|
-
|
|
257
|
-
|
|
256
|
+
Savepoint.rebuild_savepoint(dir)
|
|
257
|
+
Savepoint.append_terminal_savepoint(dir, disposition)
|
|
258
258
|
RevisionsWriter.append!(
|
|
259
259
|
dir,
|
|
260
260
|
why: "reconstruct missing operational savepoint.md (generic operational-gap predicate)",
|
|
261
261
|
rule: "savepoint-operational-reconstruction",
|
|
262
262
|
prior_location: "#{intent}/savepoint.md",
|
|
263
|
-
change: "savepoint.md rebuilt from disk (
|
|
264
|
-
"#{disposition} echo appended (
|
|
263
|
+
change: "savepoint.md rebuilt from disk (Savepoint.rebuild_savepoint) plus Done " \
|
|
264
|
+
"#{disposition} echo appended (Savepoint.append_terminal_savepoint); disposition " \
|
|
265
265
|
"read from outcome.md's own frontmatter, never invented"
|
|
266
266
|
)
|
|
267
267
|
end
|
package/scripts/new-intent
CHANGED
|
@@ -21,22 +21,37 @@
|
|
|
21
21
|
# [--tags tag,tag] [--templates <dir>]
|
|
22
22
|
#
|
|
23
23
|
# Exit codes: 0 (born complete), 1 (scaffold failed self-validation or bad input).
|
|
24
|
+
#
|
|
25
|
+
# A second, unrelated mode (intent 297):
|
|
26
|
+
# new-intent --tmp [--store <dir>] [--day YYYYMMDD] [--author <name>] \
|
|
27
|
+
# [--templates <dir>]
|
|
28
|
+
#
|
|
29
|
+
# `--tmp` scaffolds the shared session intent day ledger under the global
|
|
30
|
+
# store's `.sessions/<YYYYMMDD>/` instead of allocating a real intent id. It
|
|
31
|
+
# is a thin CLI over SessionLedger.open_day (the single scaffold
|
|
32
|
+
# implementation, spec D12) and never touches the id-allocation path above:
|
|
33
|
+
# no folgezettel-id call, no actions/ or resources/ dirs, no sentinel
|
|
34
|
+
# placeholders, no born savepoint stamp, no Links projection. Stdout is
|
|
35
|
+
# exactly one line, `created <dir>` or `joined <dir>`; exit 0 either way,
|
|
36
|
+
# exit 1 on bad input or failed self-validation.
|
|
24
37
|
|
|
25
38
|
require "fileutils"
|
|
26
39
|
require "date"
|
|
27
|
-
require_relative "lib/
|
|
40
|
+
require_relative "lib/savepoint"
|
|
28
41
|
require_relative "lib/intent_validator"
|
|
29
42
|
require_relative "lib/graph_rebuild"
|
|
30
43
|
require_relative "lib/links_projection"
|
|
31
44
|
require_relative "lib/links_section"
|
|
32
45
|
require_relative "lib/store_discovery"
|
|
46
|
+
require_relative "lib/session_ledger"
|
|
33
47
|
|
|
34
48
|
# --- Explicit flag parsing (no eval, no global injection) ------------------
|
|
35
49
|
|
|
36
50
|
def parse_args(argv)
|
|
37
51
|
opts = {
|
|
38
52
|
store: nil, intent: nil, slug: nil, parent: nil,
|
|
39
|
-
author: "claude-code", sources: [], tags: [], templates: nil
|
|
53
|
+
author: "claude-code", sources: [], tags: [], templates: nil,
|
|
54
|
+
tmp: false, day: nil
|
|
40
55
|
}
|
|
41
56
|
i = 0
|
|
42
57
|
while i < argv.length
|
|
@@ -50,6 +65,8 @@ def parse_args(argv)
|
|
|
50
65
|
when "--sources" then opts[:sources] = split_list(argv[i += 1])
|
|
51
66
|
when "--tags" then opts[:tags] = split_list(argv[i += 1])
|
|
52
67
|
when "--templates" then opts[:templates] = argv[i += 1]
|
|
68
|
+
when "--tmp" then opts[:tmp] = true
|
|
69
|
+
when "--day" then opts[:day] = argv[i += 1]
|
|
53
70
|
else
|
|
54
71
|
abort "new-intent: unknown argument #{arg.inspect}"
|
|
55
72
|
end
|
|
@@ -253,8 +270,50 @@ def project_links_for(file_path, referer_store_key, maps)
|
|
|
253
270
|
true
|
|
254
271
|
end
|
|
255
272
|
|
|
273
|
+
# The --tmp branch (intent 297, spec D1): scaffold the shared session intent
|
|
274
|
+
# day ledger instead of a real intent. A thin CLI over
|
|
275
|
+
# SessionLedger.open_day, the single scaffold implementation (spec D12); this
|
|
276
|
+
# method never allocates a folgezettel id, never creates actions/ or
|
|
277
|
+
# resources/, never writes a sentinel placeholder, never stamps a savepoint
|
|
278
|
+
# line, and never runs the Links projection build.
|
|
279
|
+
def run_tmp(opts)
|
|
280
|
+
{ intent: "--intent", slug: "--slug", parent: "--parent" }.each do |key, flag|
|
|
281
|
+
abort "new-intent: #{flag} is not valid with --tmp" if opts[key] && !opts[key].to_s.empty?
|
|
282
|
+
end
|
|
283
|
+
abort "new-intent: --sources is not valid with --tmp" unless opts[:sources].empty?
|
|
284
|
+
abort "new-intent: --tags is not valid with --tmp" unless opts[:tags].empty?
|
|
285
|
+
|
|
286
|
+
store = opts[:store] ? expand(opts[:store]) : expand(File.join(ENV.fetch("PLASTIC_HOME", "~/.plastic"), "store"))
|
|
287
|
+
templates = opts[:templates] ? expand(opts[:templates]) : default_templates_dir
|
|
288
|
+
abort "new-intent: templates dir not found: #{templates}" unless Dir.exist?(templates)
|
|
289
|
+
|
|
290
|
+
day = opts[:day] || SessionLedger.day_id
|
|
291
|
+
unless SessionLedger.valid_day_id?(day)
|
|
292
|
+
abort "new-intent: --day must be eight digits parsing as a real date, got #{opts[:day].inspect}"
|
|
293
|
+
end
|
|
294
|
+
|
|
295
|
+
result = SessionLedger.open_day(store: store, day: day, templates: templates, author: opts[:author], now: Time.now)
|
|
296
|
+
|
|
297
|
+
plastic_home, = store_context(store)
|
|
298
|
+
known_stores = StoreDiscovery.known_slugs(plastic_home)
|
|
299
|
+
validation = IntentValidator.validate(result[:dir], known_stores: known_stores)
|
|
300
|
+
unless validation[:ok]
|
|
301
|
+
warn "new-intent: scaffolded day ledger is NOT born complete:"
|
|
302
|
+
validation[:missing].each { |f| warn " missing field: #{f}" }
|
|
303
|
+
validation[:errors].each { |e| warn " #{e}" }
|
|
304
|
+
exit 1
|
|
305
|
+
end
|
|
306
|
+
|
|
307
|
+
puts "#{result[:created] ? "created" : "joined"} #{result[:dir]}"
|
|
308
|
+
exit 0
|
|
309
|
+
end
|
|
310
|
+
|
|
256
311
|
def main(argv)
|
|
257
312
|
opts = parse_args(argv)
|
|
313
|
+
return run_tmp(opts) if opts[:tmp]
|
|
314
|
+
|
|
315
|
+
abort "new-intent: --day is only valid with --tmp" if opts[:day]
|
|
316
|
+
|
|
258
317
|
abort "new-intent: --store is required" if opts[:store].nil? || opts[:store].empty?
|
|
259
318
|
abort "new-intent: --intent is required" if opts[:intent].nil? || opts[:intent].empty?
|
|
260
319
|
abort "new-intent: --slug is required" if opts[:slug].nil? || opts[:slug].empty?
|
|
@@ -338,7 +397,7 @@ def main(argv)
|
|
|
338
397
|
template_path = File.join(templates, name)
|
|
339
398
|
body = File.exist?(template_path) ? File.read(template_path) : ""
|
|
340
399
|
body = render_tokens(body, { "INTENT_NAME" => opts[:intent] })
|
|
341
|
-
File.write(File.join(intent_dir, name), "#{
|
|
400
|
+
File.write(File.join(intent_dir, name), "#{Savepoint::PLACEHOLDER_SENTINEL}\n#{body}")
|
|
342
401
|
end
|
|
343
402
|
|
|
344
403
|
# 6. Stamp the born savepoint line (intent 81). The first ledger line is the
|
|
@@ -347,7 +406,7 @@ def main(argv)
|
|
|
347
406
|
# sessions / harnesses). The intent file is never a sentinel placeholder, so
|
|
348
407
|
# append_savepoint records `What {id}--{slug}.md`. Idempotent: a later gate
|
|
349
408
|
# fire adds nothing.
|
|
350
|
-
|
|
409
|
+
Savepoint.append_savepoint(intent_dir, intent_file)
|
|
351
410
|
|
|
352
411
|
# 7. Self-validate (frontmatter + sanctioned sections), including that every
|
|
353
412
|
# sources/chain cross-store token names a real store (intent 189 D3).
|