@zalom/plastic 1.14.1 → 2.0.0-alpha.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/PLASTIC.md +101 -294
- package/README.md +5 -6
- package/agents/plastic-advisor.md +18 -17
- package/agents/plastic-enforcer.md +57 -69
- package/agents/plastic-executor.md +34 -20
- package/agents/plastic-faux-advisor.md +18 -18
- package/bin/lib/context_budget.rb +453 -0
- package/bin/plastic-bench +78 -0
- package/config_asks.yml +38 -0
- package/hooks/capture +4 -0
- package/hooks/close +5 -0
- package/hooks/hooks.json +14 -42
- package/hooks/message-display +81 -0
- package/hooks/record +4 -0
- package/hooks/savepoint +5 -5
- package/package.json +3 -2
- package/scripts/agent-report +15 -10
- package/scripts/append-ledger +208 -0
- package/scripts/codex-hook +78 -90
- package/scripts/dashboard.rb +43 -14
- package/scripts/day-summary +53 -0
- package/scripts/doctor.rb +269 -43
- package/scripts/end-intent +170 -76
- package/scripts/exec-worktree +11 -20
- package/scripts/file-session-intent +61 -0
- package/scripts/hook-capture +255 -0
- package/scripts/hook-close +36 -0
- package/scripts/hook-message-display +74 -0
- package/scripts/hook-record +157 -0
- package/scripts/hook-savepoint +45 -0
- package/scripts/hook-session-start +128 -12
- package/scripts/install.rb +8 -6
- package/scripts/intent-screen +77 -0
- package/scripts/lib/agent_models.rb +1 -7
- package/scripts/lib/arm.rb +308 -0
- package/scripts/lib/backfill_intent.rb +316 -0
- package/scripts/lib/bridge.rb +24 -1597
- package/scripts/lib/compact_instructions.rb +56 -0
- package/scripts/lib/day_summary.rb +211 -0
- package/scripts/lib/doctor_core.rb +66 -88
- package/scripts/lib/doctor_session_ledger.rb +158 -0
- package/scripts/lib/exec_worktree.rb +14 -60
- package/scripts/lib/handoff.rb +184 -0
- package/scripts/lib/harness_text.rb +5 -4
- package/scripts/lib/hook_registry.rb +57 -106
- package/scripts/lib/insights.rb +3 -4
- package/scripts/lib/installer_core.rb +136 -41
- package/scripts/lib/intent_screen.rb +309 -0
- package/scripts/lib/intent_screen_ansi.rb +262 -0
- package/scripts/lib/lock.rb +6 -26
- package/scripts/lib/message_display.rb +290 -0
- package/scripts/lib/outcome_guard.rb +7 -5
- package/scripts/lib/project_validator.rb +30 -1
- package/scripts/lib/report_screen.rb +648 -0
- 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 +334 -0
- package/scripts/lib/scaffold_intent.rb +13 -179
- package/scripts/lib/screen_paint.rb +276 -0
- package/scripts/lib/session_backfill.rb +184 -0
- package/scripts/lib/session_close.rb +104 -0
- package/scripts/lib/session_git.rb +500 -0
- package/scripts/lib/session_ledger.rb +621 -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 +101 -41
- package/scripts/promote-session-item +150 -0
- package/scripts/read-config +3 -0
- package/scripts/rebuild-graph +1 -1
- package/scripts/report-screen +120 -0
- package/scripts/roadmap-next +2 -2
- package/scripts/rollback.rb +48 -3
- package/scripts/savepoint-note +67 -0
- package/scripts/scaffold-intent +37 -63
- package/scripts/session-commit +116 -0
- package/scripts/spawn-preamble +14 -7
- package/scripts/update.rb +7 -3
- package/scripts/write-handoff +60 -0
- package/skills/agent-advisor/SKILL.md +3 -3
- package/skills/agent-advisor/references/advisor-protocol.md +20 -20
- package/skills/auto/SKILL.md +223 -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 +62 -56
- 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 +62 -58
- 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 +142 -91
- package/skills/intent-continuing/references/boarding-matrix.md +34 -0
- package/skills/intent-continuing/references/context-management.md +3 -3
- package/skills/intent-creating/SKILL.md +2 -2
- package/skills/intent-creating/references/lifecycle.md +1 -1
- package/skills/intent-ending/SKILL.md +34 -37
- package/skills/intent-ending/evals/evals.json +4 -4
- package/skills/intent-executing/SKILL.md +15 -46
- package/skills/intent-executing/plan-reviewer-prompt.md +39 -0
- package/skills/intent-speccing/SKILL.md +108 -51
- package/skills/intent-speccing/evals/evals.json +5 -5
- package/skills/intent-speccing/references/per-section-fill-rules.md +4 -4
- package/skills/intent-speccing/references/self-verify-checklist.md +1 -2
- package/skills/project-creating/SKILL.md +1 -1
- package/skills/releasing/SKILL.md +7 -8
- package/skills/tutorial/SKILL.md +2 -2
- package/skills/tutorial/references/track-1-guided.md +16 -18
- package/skills/tutorial/references/track-2-auto.md +12 -13
- package/skills/tutorial/references/track-3-projects-and-roadmaps.md +2 -2
- package/templates/agents.md +4 -7
- package/templates/config.yml +5 -0
- package/templates/intent-screen.md +17 -0
- package/templates/outcome.md +14 -1
- package/templates/project.yml +7 -0
- package/templates/report-state.md +11 -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,621 @@
|
|
|
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
|
+
# --- capture_worthy? (spec D2, D7; supersedes 298 D2(c)) -------------------
|
|
190
|
+
|
|
191
|
+
# One complete top-level harness envelope tag block: "<name ...>...</name>".
|
|
192
|
+
# Non-greedy (.*?) so sibling blocks are each matched on their own rather
|
|
193
|
+
# than one match spanning from the first block's opening tag all the way to
|
|
194
|
+
# the LAST block's closing tag (post-execution review item 3: an envelope
|
|
195
|
+
# on both sides of real work, "<system-reminder>...</system-reminder>\nfix
|
|
196
|
+
# the parser\n<task-notification>...</task-notification>", must not be
|
|
197
|
+
# read as one giant envelope swallowing the work in the middle).
|
|
198
|
+
ENVELOPE_BLOCK_RE = /<([A-Za-z][\w-]*)(?:\s[^>]*)?>.*?<\/\1>/m
|
|
199
|
+
private_constant :ENVELOPE_BLOCK_RE
|
|
200
|
+
|
|
201
|
+
# The whole prompt, case- and whitespace-insensitively, and nothing else
|
|
202
|
+
# (rule 3, D2): a trigger word inside a longer real instruction ("continue
|
|
203
|
+
# the dashboard fix and then release") must not match this.
|
|
204
|
+
BARE_TRIGGERS = %w[continue auto].freeze
|
|
205
|
+
private_constant :BARE_TRIGGERS
|
|
206
|
+
|
|
207
|
+
# Words whose presence marks a prompt as actionable work (rule 4's escape
|
|
208
|
+
# hatch, D2's accept bias). Deliberately excludes common nouns that also
|
|
209
|
+
# read as everyday verbs in casual remarks (e.g. "release", "ship", "plan"):
|
|
210
|
+
# including them would make ordinary conversation about a past release or
|
|
211
|
+
# plan look like a work request. Matched with an optional inflection suffix
|
|
212
|
+
# (post-execution review BLOCKER): the bare stems alone missed "fixed",
|
|
213
|
+
# "updated", "added", "reviewed", "implemented" -- exactly the past-tense
|
|
214
|
+
# and -ing forms real work summaries use.
|
|
215
|
+
WORK_MARKER_WORDS = %w[
|
|
216
|
+
fix add remove delete update upgrade implement write build create refactor
|
|
217
|
+
debug investigate review test deploy commit merge revert rename configure
|
|
218
|
+
install migrate document generate draft resolve help need want make change
|
|
219
|
+
setup
|
|
220
|
+
].freeze
|
|
221
|
+
private_constant :WORK_MARKER_WORDS
|
|
222
|
+
|
|
223
|
+
WORK_MARKER_PHRASES = [
|
|
224
|
+
"can you", "could you", "would you", "let's", "let us", "set up", "look into", "figure out",
|
|
225
|
+
].freeze
|
|
226
|
+
private_constant :WORK_MARKER_PHRASES
|
|
227
|
+
|
|
228
|
+
WORK_MARKER_RE = /\b(?:#{WORK_MARKER_WORDS.join("|")})(?:s|d|ed|ing)?\b/i
|
|
229
|
+
private_constant :WORK_MARKER_RE
|
|
230
|
+
|
|
231
|
+
# A first word that reads as an interrogative opener, checked case-
|
|
232
|
+
# insensitively against the prompt's first whitespace-separated token.
|
|
233
|
+
# Post-execution review BLOCKER: trimmed from the original, wider list
|
|
234
|
+
# (which also carried "how", "when", "where", "was", "were", "do", "did",
|
|
235
|
+
# "will", "shall", "should") down to words that open a genuine QUESTION at
|
|
236
|
+
# least as often as an ordinary command or request. Measured against 43
|
|
237
|
+
# invented and 27 real day-ledger prompts: the dropped words open ordinary
|
|
238
|
+
# work requests ("do the release now...", "when you are done, tag the
|
|
239
|
+
# release...", "will you push that branch...", "should I bump the version
|
|
240
|
+
# files...") far more often than they open a bare question worth rejecting.
|
|
241
|
+
QUESTION_STARTERS = %w[
|
|
242
|
+
what why who whom whose which is are am does can could would
|
|
243
|
+
].freeze
|
|
244
|
+
private_constant :QUESTION_STARTERS
|
|
245
|
+
|
|
246
|
+
# A narrow set of retrospective-remark shapes ("that release went smoother
|
|
247
|
+
# than the last one"): comparative or evaluative observations about how
|
|
248
|
+
# something already went. Deliberately narrow (D2's accept bias): a broad
|
|
249
|
+
# "any declarative sentence with no recognized verb" rule would also catch
|
|
250
|
+
# ordinary work summaries like "harness text wins the pending line", which
|
|
251
|
+
# must stay accepted.
|
|
252
|
+
REMARK_PATTERNS = [
|
|
253
|
+
/\bwent\s+\w+\s+than\b/i,
|
|
254
|
+
/\bwent\s+(?:well|badly|smoothly|great|poorly|terribly)\b/i,
|
|
255
|
+
].freeze
|
|
256
|
+
private_constant :REMARK_PATTERNS
|
|
257
|
+
|
|
258
|
+
# True iff nothing but harness envelope tag block(s) -- and whitespace --
|
|
259
|
+
# remain once every complete top-level block is stripped out. A prompt
|
|
260
|
+
# that is one envelope alone, or several envelopes with no other content,
|
|
261
|
+
# matches; a prompt carrying real work anywhere outside an envelope (before,
|
|
262
|
+
# after, or between several of them) does not.
|
|
263
|
+
def whole_prompt_envelope?(stripped)
|
|
264
|
+
stripped.gsub(ENVELOPE_BLOCK_RE, "").strip.empty?
|
|
265
|
+
end
|
|
266
|
+
|
|
267
|
+
def bare_trigger?(stripped)
|
|
268
|
+
BARE_TRIGGERS.include?(stripped.downcase)
|
|
269
|
+
end
|
|
270
|
+
|
|
271
|
+
def work_marker?(text)
|
|
272
|
+
return true if WORK_MARKER_RE.match?(text)
|
|
273
|
+
|
|
274
|
+
downcased = text.downcase
|
|
275
|
+
WORK_MARKER_PHRASES.any? { |p| downcased.include?(p) }
|
|
276
|
+
end
|
|
277
|
+
|
|
278
|
+
def interrogative?(stripped)
|
|
279
|
+
return true if stripped.end_with?("?")
|
|
280
|
+
|
|
281
|
+
first_word = stripped.split(/\s+/).first.to_s.downcase.gsub(/[^a-z]/, "")
|
|
282
|
+
QUESTION_STARTERS.include?(first_word)
|
|
283
|
+
end
|
|
284
|
+
|
|
285
|
+
def bare_remark?(stripped)
|
|
286
|
+
REMARK_PATTERNS.any? { |re| re.match?(stripped) }
|
|
287
|
+
end
|
|
288
|
+
|
|
289
|
+
# Internal helpers only: #capture_worthy? is the sole public contract
|
|
290
|
+
# (post-execution review item 9).
|
|
291
|
+
private_class_method :whole_prompt_envelope?, :bare_trigger?, :work_marker?, :interrogative?, :bare_remark?
|
|
292
|
+
|
|
293
|
+
# Whether `prompt` earns a pending checklist line (spec D2). Bias is
|
|
294
|
+
# ACCEPT: this rejects only on four named rules -- the 10-char floor (on
|
|
295
|
+
# its own collapsed copy), a whole-prompt harness envelope, a bare
|
|
296
|
+
# continue/auto trigger, and an interrogative or bare-remark prompt
|
|
297
|
+
# carrying no work marker -- and accepts everything else, including a
|
|
298
|
+
# work-shaped question and an envelope followed by real work. Takes the
|
|
299
|
+
# RAW prompt (not the sanitized/truncated line text) so rule 2 sees the
|
|
300
|
+
# prompt's true first character and multi-line shape.
|
|
301
|
+
def capture_worthy?(prompt)
|
|
302
|
+
raw = prompt.to_s
|
|
303
|
+
return false if sanitize_summary(raw).length < 10
|
|
304
|
+
|
|
305
|
+
stripped = raw.strip
|
|
306
|
+
return false if whole_prompt_envelope?(stripped)
|
|
307
|
+
return false if bare_trigger?(stripped)
|
|
308
|
+
return false if !work_marker?(stripped) && (interrogative?(stripped) || bare_remark?(stripped))
|
|
309
|
+
|
|
310
|
+
true
|
|
311
|
+
end
|
|
312
|
+
|
|
313
|
+
# One LF-terminated checklist line, byte exact per spec D5. The state
|
|
314
|
+
# marker is fixed width across all three states, which is what lets a later
|
|
315
|
+
# promote or tick be a one-byte write at a known offset.
|
|
316
|
+
def checklist_line(state, session, project, summary)
|
|
317
|
+
marker = STATES.fetch(state) { raise ArgumentError, "unknown checklist state: #{state.inspect}" }
|
|
318
|
+
"- [#{marker}] [#{session}] [#{project}] #{summary}\n"
|
|
319
|
+
end
|
|
320
|
+
|
|
321
|
+
# One LF-terminated savepoint line, byte exact per spec D5. Separators are
|
|
322
|
+
# two spaces, so `split(/\s{2,}/)` yields exactly three parts. The
|
|
323
|
+
# timestamp is UTC ISO 8601, matching every other savepoint line in the
|
|
324
|
+
# store; the day id stays local wall clock, an intentional asymmetry.
|
|
325
|
+
def savepoint_line(event, session, project, summary, now:)
|
|
326
|
+
raise ArgumentError, "unknown savepoint event: #{event.inspect}" unless EVENTS.include?(event)
|
|
327
|
+
|
|
328
|
+
timestamp = now.utc.strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
329
|
+
"#{timestamp} #{event} [#{session}] [#{project}] #{summary}\n"
|
|
330
|
+
end
|
|
331
|
+
|
|
332
|
+
# The inverse of #checklist_line: parse a checklist line positionally (the
|
|
333
|
+
# state marker, the first bracket group, the second bracket group, then the
|
|
334
|
+
# rest as the summary), so a bracket typed inside a summary is never read
|
|
335
|
+
# as a tag. Returns nil when the line does not match.
|
|
336
|
+
CHECKLIST_LINE_RE = /\A- \[(.)\] \[([^\]]*)\] \[([^\]]*)\] (.*)\z/m.freeze
|
|
337
|
+
private_constant :CHECKLIST_LINE_RE
|
|
338
|
+
|
|
339
|
+
def parse_checklist_line(line)
|
|
340
|
+
# #scrub replaces any invalid byte with U+FFFD so a stray non-UTF-8 byte
|
|
341
|
+
# anywhere in checklist.md never raises ArgumentError out of the regex
|
|
342
|
+
# match; it only ever affects that one line's parsed summary. Callers
|
|
343
|
+
# that need byte-exact offsets (namely #set_state) must measure against
|
|
344
|
+
# the UNSCRUBBED line, since #scrub can change a line's bytesize.
|
|
345
|
+
match = CHECKLIST_LINE_RE.match(line.to_s.chomp.scrub)
|
|
346
|
+
return nil unless match
|
|
347
|
+
|
|
348
|
+
marker, session, project, summary = match.captures
|
|
349
|
+
state = STATES.key(marker)
|
|
350
|
+
return nil unless state
|
|
351
|
+
|
|
352
|
+
{ state: state, session: session, project: project, summary: summary }
|
|
353
|
+
end
|
|
354
|
+
|
|
355
|
+
# --- Token rendering (internal) --------------------------------------------
|
|
356
|
+
|
|
357
|
+
# Block-form gsub, mirroring scripts/new-intent#render_tokens: a String
|
|
358
|
+
# replacement argument reinterprets backslash sequences, so the block form
|
|
359
|
+
# substitutes each value literally.
|
|
360
|
+
def render_tokens(text, tokens)
|
|
361
|
+
tokens.reduce(text) { |acc, (key, value)| acc.gsub("{{#{key}}}") { value.to_s } }
|
|
362
|
+
end
|
|
363
|
+
|
|
364
|
+
# --- Locked writes and reads (spec D8) --------------------------------------
|
|
365
|
+
|
|
366
|
+
# Append `line` to `path`, taking an exclusive lock on the target file
|
|
367
|
+
# itself (never a sibling lock file, never via a rename). Opens
|
|
368
|
+
# WRONLY | APPEND | CREAT so a concurrent append always lands at the
|
|
369
|
+
# current end of file. Once the lock is held, writes `header` first only
|
|
370
|
+
# when the file is still size zero (the size check happens under the lock
|
|
371
|
+
# on purpose: O_CREAT without O_EXCL hands every racer the same inode, so
|
|
372
|
+
# the lock serializes them and exactly one racer sees size zero). Then
|
|
373
|
+
# writes the full line in one call. Rescues SystemCallError from flock
|
|
374
|
+
# only: on a filesystem without flock support, the append proceeds
|
|
375
|
+
# unlocked, since a single O_APPEND write still lands whole there. Always
|
|
376
|
+
# returns true.
|
|
377
|
+
def append_line(path, line, header: nil, flock: DEFAULT_FLOCK)
|
|
378
|
+
handle = File.open(path, File::WRONLY | File::APPEND | File::CREAT, 0o644)
|
|
379
|
+
begin
|
|
380
|
+
begin
|
|
381
|
+
flock.call(handle, File::LOCK_EX)
|
|
382
|
+
rescue SystemCallError
|
|
383
|
+
nil
|
|
384
|
+
end
|
|
385
|
+
|
|
386
|
+
handle.write(header) if header && handle.size.zero?
|
|
387
|
+
handle.write(line)
|
|
388
|
+
# Flush explicitly before unlocking. MRI happens to flush a writable
|
|
389
|
+
# handle's buffer as a side effect inside rb_file_flock, but that is
|
|
390
|
+
# undocumented behavior, so flush on purpose rather than depend on it.
|
|
391
|
+
handle.flush
|
|
392
|
+
|
|
393
|
+
begin
|
|
394
|
+
handle.flock(File::LOCK_UN)
|
|
395
|
+
rescue SystemCallError
|
|
396
|
+
nil
|
|
397
|
+
end
|
|
398
|
+
ensure
|
|
399
|
+
handle.close
|
|
400
|
+
end
|
|
401
|
+
true
|
|
402
|
+
end
|
|
403
|
+
|
|
404
|
+
# Flip the newest line (last in file order) whose session tag equals
|
|
405
|
+
# `session` and whose state equals `from` to `to`, narrowed to lines whose
|
|
406
|
+
# summary contains `match` when given (spec D7). The lock is taken on the
|
|
407
|
+
# target file itself. Once held, the file is scanned by byte offset, and
|
|
408
|
+
# exactly one byte (the state marker, at the matched line's start offset
|
|
409
|
+
# plus 3, past the "- [" prefix) is overwritten with `IO#pwrite`, so every
|
|
410
|
+
# other byte in the file stays identical: this is exactly why the pending
|
|
411
|
+
# marker is `[~]`, a fixed width shared with `[ ]` and `[x]`, rather than a
|
|
412
|
+
# bare tilde.
|
|
413
|
+
#
|
|
414
|
+
# Identifying the target line and flipping it happen inside the SAME
|
|
415
|
+
# LOCK_EX hold, on purpose: an earlier version identified the target under
|
|
416
|
+
# a released LOCK_SH, in a separate call, then re-identified and flipped it
|
|
417
|
+
# under a fresh LOCK_EX. Two concurrent promoters could both read the same
|
|
418
|
+
# "newest pending" line before either flipped anything, each then flip a
|
|
419
|
+
# DIFFERENT line under their own (correctly serialized) LOCK_EX, and the
|
|
420
|
+
# caller's earlier lookup would go stale, naming the wrong line in a
|
|
421
|
+
# --savepoint entry. Returning the flipped line's own summary from inside
|
|
422
|
+
# this lock is what makes that identify-and-flip atomic, so a caller never
|
|
423
|
+
# needs a second, separately-locked read to learn what it just changed.
|
|
424
|
+
#
|
|
425
|
+
# Returns the flipped line's summary (a String) when a byte changed, or nil
|
|
426
|
+
# (writing nothing) when the file does not exist or nothing matches. Raises
|
|
427
|
+
# LockUnavailableError, refusing to write at all, when the flock cannot be
|
|
428
|
+
# taken (filesystem without flock support): unlike an append, an in-place
|
|
429
|
+
# edit cannot fall back to unlocked, since a racing writer could tear the
|
|
430
|
+
# read-modify-write. `flock:` is a test seam (see DEFAULT_FLOCK); production
|
|
431
|
+
# callers never pass it.
|
|
432
|
+
def set_state(path, from:, to:, session:, match: nil, flock: DEFAULT_FLOCK)
|
|
433
|
+
return nil unless File.exist?(path)
|
|
434
|
+
|
|
435
|
+
STATES.fetch(from)
|
|
436
|
+
to_marker = STATES.fetch(to)
|
|
437
|
+
|
|
438
|
+
handle = File.open(path, File::RDWR)
|
|
439
|
+
locked = true
|
|
440
|
+
begin
|
|
441
|
+
flock.call(handle, File::LOCK_EX)
|
|
442
|
+
rescue SystemCallError
|
|
443
|
+
locked = false
|
|
444
|
+
end
|
|
445
|
+
|
|
446
|
+
unless locked
|
|
447
|
+
handle.close
|
|
448
|
+
raise LockUnavailableError, "cannot take an exclusive lock on #{path}"
|
|
449
|
+
end
|
|
450
|
+
|
|
451
|
+
begin
|
|
452
|
+
content = handle.read
|
|
453
|
+
target_offset = nil
|
|
454
|
+
target_summary = nil
|
|
455
|
+
offset = 0
|
|
456
|
+
|
|
457
|
+
content.each_line do |raw_line|
|
|
458
|
+
parsed = parse_checklist_line(raw_line)
|
|
459
|
+
if parsed && (session.nil? || parsed[:session] == session) && parsed[:state] == from &&
|
|
460
|
+
(match.nil? || parsed[:summary].include?(match))
|
|
461
|
+
target_offset = offset + 3 # past the "- [" prefix
|
|
462
|
+
target_summary = parsed[:summary]
|
|
463
|
+
end
|
|
464
|
+
# Accumulate over the UNSCRUBBED raw_line, never the copy
|
|
465
|
+
# #parse_checklist_line scrubs internally for matching: #scrub can
|
|
466
|
+
# change a line's bytesize, and pwrite below must land at the true
|
|
467
|
+
# on-disk byte offset.
|
|
468
|
+
offset += raw_line.bytesize
|
|
469
|
+
end
|
|
470
|
+
|
|
471
|
+
if target_offset.nil?
|
|
472
|
+
nil
|
|
473
|
+
else
|
|
474
|
+
handle.pwrite(to_marker, target_offset)
|
|
475
|
+
handle.flush
|
|
476
|
+
target_summary
|
|
477
|
+
end
|
|
478
|
+
ensure
|
|
479
|
+
begin
|
|
480
|
+
handle.flock(File::LOCK_UN)
|
|
481
|
+
rescue SystemCallError
|
|
482
|
+
nil
|
|
483
|
+
end
|
|
484
|
+
handle.close
|
|
485
|
+
end
|
|
486
|
+
end
|
|
487
|
+
|
|
488
|
+
# Flip every checklist line in state `from` to state `to` for one session
|
|
489
|
+
# (or any session when `session` is nil), narrowed to summaries containing
|
|
490
|
+
# `match` when given (intent 301). Same lock discipline as #set_state: one
|
|
491
|
+
# LOCK_EX for the whole read-scan-write, one pwrite of one byte per
|
|
492
|
+
# flipped line at its measured offset, never a whole-file rewrite, and a
|
|
493
|
+
# LockUnavailableError rather than any unlocked write. Returns the count of
|
|
494
|
+
# lines flipped, 0 when the file is absent or nothing matches.
|
|
495
|
+
def flip_all(path, from:, to:, session: nil, match: nil, flock: DEFAULT_FLOCK)
|
|
496
|
+
return 0 unless File.exist?(path)
|
|
497
|
+
|
|
498
|
+
STATES.fetch(from)
|
|
499
|
+
to_marker = STATES.fetch(to)
|
|
500
|
+
handle = File.open(path, File::RDWR)
|
|
501
|
+
locked = true
|
|
502
|
+
begin
|
|
503
|
+
flock.call(handle, File::LOCK_EX)
|
|
504
|
+
rescue SystemCallError
|
|
505
|
+
locked = false
|
|
506
|
+
end
|
|
507
|
+
unless locked
|
|
508
|
+
handle.close
|
|
509
|
+
raise LockUnavailableError, "cannot take an exclusive lock on #{path}"
|
|
510
|
+
end
|
|
511
|
+
|
|
512
|
+
begin
|
|
513
|
+
content = handle.read
|
|
514
|
+
offsets = []
|
|
515
|
+
offset = 0
|
|
516
|
+
content.each_line do |raw_line|
|
|
517
|
+
parsed = parse_checklist_line(raw_line)
|
|
518
|
+
if parsed && (session.nil? || parsed[:session] == session) && parsed[:state] == from &&
|
|
519
|
+
(match.nil? || parsed[:summary].include?(match))
|
|
520
|
+
offsets << offset + 3
|
|
521
|
+
end
|
|
522
|
+
offset += raw_line.bytesize
|
|
523
|
+
end
|
|
524
|
+
offsets.each { |o| handle.pwrite(to_marker, o) }
|
|
525
|
+
handle.flush unless offsets.empty?
|
|
526
|
+
offsets.size
|
|
527
|
+
ensure
|
|
528
|
+
begin
|
|
529
|
+
handle.flock(File::LOCK_UN)
|
|
530
|
+
rescue SystemCallError
|
|
531
|
+
nil
|
|
532
|
+
end
|
|
533
|
+
handle.close
|
|
534
|
+
end
|
|
535
|
+
end
|
|
536
|
+
|
|
537
|
+
# Read `path` under a shared lock, returning its content. Returns an empty
|
|
538
|
+
# string when the file does not exist. Rescues SystemCallError from flock
|
|
539
|
+
# and reads anyway.
|
|
540
|
+
def read_locked(path)
|
|
541
|
+
return "" unless File.exist?(path)
|
|
542
|
+
|
|
543
|
+
handle = File.open(path, File::RDONLY)
|
|
544
|
+
begin
|
|
545
|
+
begin
|
|
546
|
+
handle.flock(File::LOCK_SH)
|
|
547
|
+
rescue SystemCallError
|
|
548
|
+
nil
|
|
549
|
+
end
|
|
550
|
+
handle.read
|
|
551
|
+
ensure
|
|
552
|
+
begin
|
|
553
|
+
handle.flock(File::LOCK_UN)
|
|
554
|
+
rescue SystemCallError
|
|
555
|
+
nil
|
|
556
|
+
end
|
|
557
|
+
handle.close
|
|
558
|
+
end
|
|
559
|
+
end
|
|
560
|
+
|
|
561
|
+
# --- The day scaffold (spec D12) --------------------------------------------
|
|
562
|
+
|
|
563
|
+
# The one and only scaffold implementation. `new-intent --tmp` is its CLI,
|
|
564
|
+
# and `append-ledger` calls it directly on EVERY invocation (cheap and
|
|
565
|
+
# idempotent, so there is no cheaper-but-wrong guard to key on instead), so
|
|
566
|
+
# a capture that crosses midnight never fails and never needs a second
|
|
567
|
+
# process. Creates exactly `<day>/<day>.md`: no checklist.md, no
|
|
568
|
+
# savepoint.md, no actions/, no resources/. `checklist.md` and
|
|
569
|
+
# `savepoint.md` come into existence on first append, written by
|
|
570
|
+
# append-ledger under the lock, which is why this method never touches
|
|
571
|
+
# them.
|
|
572
|
+
#
|
|
573
|
+
# Create versus join is decided by opening the day file with
|
|
574
|
+
# File::CREAT | File::EXCL: the winner renders the template and returns
|
|
575
|
+
# created: true; every loser, including a repair of a crashed
|
|
576
|
+
# mid-scaffold with no md file yet, returns created: false without
|
|
577
|
+
# changing a byte. DATE (the day's own calendar date, used in the `intent:`
|
|
578
|
+
# line) is derived from `day`, not from `now`, so an explicit --day renders
|
|
579
|
+
# its own date rather than today's; `now:` instead sources CREATED, the
|
|
580
|
+
# `created:` frontmatter field, since that field records when the scaffold
|
|
581
|
+
# FILE was actually written, which can differ from the day it is for (a
|
|
582
|
+
# repair or a midnight-crossing capture can scaffold a past day's file
|
|
583
|
+
# today). Defaults to Time.now; tests inject a fixed `now:` to make the
|
|
584
|
+
# scaffold's `created:` value deterministic.
|
|
585
|
+
#
|
|
586
|
+
# If rendering fails partway (a missing or relocated templates dir, a bad
|
|
587
|
+
# day), the file this call just created is unlinked before the error
|
|
588
|
+
# re-raises, so no zero-byte or partial <day>.md is left behind to wedge
|
|
589
|
+
# every later #open_day call onto the Errno::EEXIST "already exists"
|
|
590
|
+
# branch with no file to repair.
|
|
591
|
+
def open_day(store:, day:, templates:, author:, now: Time.now)
|
|
592
|
+
dir = day_dir(store, day)
|
|
593
|
+
FileUtils.mkdir_p(dir)
|
|
594
|
+
|
|
595
|
+
file = day_file(store, day)
|
|
596
|
+
handle = begin
|
|
597
|
+
File.open(file, File::WRONLY | File::CREAT | File::EXCL, 0o644)
|
|
598
|
+
rescue Errno::EEXIST
|
|
599
|
+
nil
|
|
600
|
+
end
|
|
601
|
+
|
|
602
|
+
return { dir: dir, created: false } unless handle
|
|
603
|
+
|
|
604
|
+
begin
|
|
605
|
+
begin
|
|
606
|
+
date = Date.strptime(day, "%Y%m%d").iso8601
|
|
607
|
+
created = now.strftime("%Y-%m-%d")
|
|
608
|
+
template = File.read(File.join(templates, "session-intent.md"))
|
|
609
|
+
rendered = render_tokens(template, "DAY" => day, "DATE" => date, "CREATED" => created, "AUTHOR" => author)
|
|
610
|
+
handle.write(rendered)
|
|
611
|
+
rescue StandardError
|
|
612
|
+
File.delete(file) if File.exist?(file)
|
|
613
|
+
raise
|
|
614
|
+
end
|
|
615
|
+
ensure
|
|
616
|
+
handle.close unless handle.closed?
|
|
617
|
+
end
|
|
618
|
+
|
|
619
|
+
{ dir: dir, created: true }
|
|
620
|
+
end
|
|
621
|
+
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
|