@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
|
@@ -1,398 +0,0 @@
|
|
|
1
|
-
# encoding: UTF-8
|
|
2
|
-
# frozen_string_literal: true
|
|
3
|
-
|
|
4
|
-
# EditGates (intent 244): the five Claude PreToolUse edit-path gates as plain
|
|
5
|
-
# functions over one parsed payload. Three callers share these functions so the
|
|
6
|
-
# harnesses cannot drift: scripts/hook-edit-gates (the merged Claude dispatcher),
|
|
7
|
-
# scripts/lib/codex_edit_gates.rb (the merged Codex dispatcher, intent 251), and
|
|
8
|
-
# the retained scripts/hook-code-gate / hook-lock-gate / hook-savepoint-pre /
|
|
9
|
-
# hook-links-gate / hook-create-gate CLI wrappers, which no longer have a
|
|
10
|
-
# production caller on either harness and remain as the per-gate isolation
|
|
11
|
-
# surface for the hook test suite.
|
|
12
|
-
#
|
|
13
|
-
# A gate returns nil to allow, or a Deny. Two deny shapes coexist deliberately
|
|
14
|
-
# (spec D-b): :stderr (stderr lines plus exit 2, code-gate / links-gate /
|
|
15
|
-
# create-gate) and :json (stdout JSON plus exit 0, lock-gate, which reserves a
|
|
16
|
-
# non-zero exit for hook-internal errors). Nothing here normalizes them.
|
|
17
|
-
|
|
18
|
-
require "json"
|
|
19
|
-
require "time"
|
|
20
|
-
require_relative "hook_registry"
|
|
21
|
-
require_relative "bridge"
|
|
22
|
-
require_relative "lock"
|
|
23
|
-
require_relative "links_gate"
|
|
24
|
-
require_relative "intent_validator"
|
|
25
|
-
|
|
26
|
-
module EditGates
|
|
27
|
-
module_function
|
|
28
|
-
|
|
29
|
-
ALLOW = nil
|
|
30
|
-
|
|
31
|
-
Deny = Struct.new(:shape, :lines, :stdout, keyword_init: true)
|
|
32
|
-
|
|
33
|
-
# Everything the five gates read out of one PreToolUse payload (spec D-g).
|
|
34
|
-
# gate_path - code-gate and lock-gate: file_path || notebook_path ||
|
|
35
|
-
# relative_path, absolutized against tool_input.project_root ||
|
|
36
|
-
# payload.cwd when relative. Exactly what the old Bash shims
|
|
37
|
-
# computed with their `ruby -rjson -e` one-liners.
|
|
38
|
-
# file_path - savepoint-pre, links-gate, create-gate: tool_input.file_path
|
|
39
|
-
# only, raw. Preserving this narrower extraction is what keeps
|
|
40
|
-
# create-gate's dormant Serena gap byte-for-byte intact.
|
|
41
|
-
# content - the RAW tool_input.content (nil when the key is absent, ""
|
|
42
|
-
# when it is explicitly empty). Kept unmixed with new_string so
|
|
43
|
-
# links-gate and create-gate's `!content.nil?` branch keeps its
|
|
44
|
-
# exact original meaning; code-gate computes its own combined
|
|
45
|
-
# content-or-new_string-or-"" value locally, mirroring what its
|
|
46
|
-
# old Bash shim computed independently for ARGV[2].
|
|
47
|
-
Context = Struct.new(:tool_name, :session, :gate_path, :file_path, :savepoint_path,
|
|
48
|
-
:content, :old_string, :new_string, :replace_all, :harness,
|
|
49
|
-
keyword_init: true) do
|
|
50
|
-
# links-gate and create-gate branch on content.nil? (key absent) vs content
|
|
51
|
-
# present (even ""). This is the raw field already, kept as a named reader
|
|
52
|
-
# so both gates read intent, not a struct field, at the call site.
|
|
53
|
-
def content_or_nil
|
|
54
|
-
content
|
|
55
|
-
end
|
|
56
|
-
end
|
|
57
|
-
|
|
58
|
-
# Per-key tool_input/tool_params fallback (post-review fix, intent 244): old
|
|
59
|
-
# links-gate and create-gate read
|
|
60
|
-
# `payload.dig("tool_input", key) || payload.dig("tool_params", key)`, each
|
|
61
|
-
# key falling back independently, NOT the whole-object pick `input =
|
|
62
|
-
# tool_input || tool_params` used for gate_path/content/etc below. This
|
|
63
|
-
# matters for a payload shaped tool_input: {} plus
|
|
64
|
-
# tool_params: { file_path: X }: the whole-object read stops at the (empty)
|
|
65
|
-
# tool_input and never sees X; the per-key read still finds it. `prefer`
|
|
66
|
-
# flips which object wins when BOTH carry the key, since savepoint-pre alone
|
|
67
|
-
# read tool_params FIRST (see savepoint_path below).
|
|
68
|
-
def per_key_field(payload, key, prefer: "tool_input")
|
|
69
|
-
other = prefer == "tool_input" ? "tool_params" : "tool_input"
|
|
70
|
-
a = payload[prefer]
|
|
71
|
-
b = payload[other]
|
|
72
|
-
(a.is_a?(Hash) ? a[key] : nil) || (b.is_a?(Hash) ? b[key] : nil)
|
|
73
|
-
end
|
|
74
|
-
|
|
75
|
-
def context_from(payload, env: ENV)
|
|
76
|
-
input = payload["tool_input"] || payload["tool_params"] || {}
|
|
77
|
-
input = {} unless input.is_a?(Hash)
|
|
78
|
-
|
|
79
|
-
raw = input["file_path"] || input["notebook_path"] || input["relative_path"] || ""
|
|
80
|
-
if !raw.empty? && !raw.start_with?("/")
|
|
81
|
-
root = input["project_root"] || payload["cwd"] || ""
|
|
82
|
-
raw = File.join(root, raw) unless root.empty?
|
|
83
|
-
end
|
|
84
|
-
|
|
85
|
-
session = payload["session_id"]
|
|
86
|
-
session = env["CLAUDE_CODE_SESSION_ID"] if session.to_s.empty?
|
|
87
|
-
|
|
88
|
-
Context.new(
|
|
89
|
-
tool_name: payload["tool_name"],
|
|
90
|
-
session: (session unless session.to_s.empty?),
|
|
91
|
-
gate_path: raw,
|
|
92
|
-
file_path: per_key_field(payload, "file_path", prefer: "tool_input"),
|
|
93
|
-
# savepoint-pre's own historical precedence, inverted from links-gate /
|
|
94
|
-
# create-gate above: old hooks/savepoint-pre read
|
|
95
|
-
# `dig("tool_params","file_path") || dig("tool_input","file_path")`.
|
|
96
|
-
savepoint_path: per_key_field(payload, "file_path", prefer: "tool_params"),
|
|
97
|
-
content: input["content"],
|
|
98
|
-
old_string: input["old_string"],
|
|
99
|
-
new_string: input["new_string"],
|
|
100
|
-
replace_all: input["replace_all"],
|
|
101
|
-
harness: "claude",
|
|
102
|
-
)
|
|
103
|
-
end
|
|
104
|
-
|
|
105
|
-
# --- savepoint-pre (intent 81): never denies; appends the `started` ledger line.
|
|
106
|
-
def savepoint_pre(ctx)
|
|
107
|
-
# ctx.savepoint_path carries context_from's tool_params-first read (fix 3);
|
|
108
|
-
# falls back to ctx.file_path for the thin CLI wrapper (hook-savepoint-pre),
|
|
109
|
-
# which only ever sets file_path directly and never populates savepoint_path.
|
|
110
|
-
path = ctx.savepoint_path || ctx.file_path
|
|
111
|
-
return ALLOW if path.nil? || path.empty?
|
|
112
|
-
|
|
113
|
-
abs = File.expand_path(path)
|
|
114
|
-
intent_dir = Bridge.intent_dir_for(abs)
|
|
115
|
-
return ALLOW unless intent_dir
|
|
116
|
-
|
|
117
|
-
begin
|
|
118
|
-
Bridge.append_started_savepoint(intent_dir, abs)
|
|
119
|
-
rescue StandardError
|
|
120
|
-
# best-effort; the ledger is rebuildable and the post line still lands
|
|
121
|
-
end
|
|
122
|
-
ALLOW
|
|
123
|
-
end
|
|
124
|
-
|
|
125
|
-
# --- lock-gate (intent 96, 111): fail-CLOSED delivery lock plus the artifact claim.
|
|
126
|
-
def lock_gate(ctx)
|
|
127
|
-
file_path = ctx.gate_path
|
|
128
|
-
return ALLOW if file_path.nil? || file_path.empty?
|
|
129
|
-
|
|
130
|
-
session = ctx.session
|
|
131
|
-
harness = ctx.harness
|
|
132
|
-
|
|
133
|
-
bridge_data = Bridge.discover_bridge(session: session, cwd: Dir.pwd)
|
|
134
|
-
reason = Bridge.lock_gate_decision(bridge_data, file_path, session: session, harness: harness)
|
|
135
|
-
|
|
136
|
-
unless reason
|
|
137
|
-
begin
|
|
138
|
-
dir = Bridge.intent_dir_for(file_path)
|
|
139
|
-
Lock.heartbeat(dir, session: session) if dir && !session.to_s.empty?
|
|
140
|
-
rescue StandardError
|
|
141
|
-
# ignore
|
|
142
|
-
end
|
|
143
|
-
|
|
144
|
-
begin
|
|
145
|
-
dir = Bridge.intent_dir_for(file_path)
|
|
146
|
-
artifact = File.basename(file_path) if dir
|
|
147
|
-
if dir && artifact
|
|
148
|
-
claim_reason = Claim.claim_gate_reason(dir, artifact, session: session, harness: harness)
|
|
149
|
-
return deny_json(claim_reason) if claim_reason
|
|
150
|
-
|
|
151
|
-
if Claim.fail_open?(dir, artifact)
|
|
152
|
-
$stderr.puts "plastic claim gate: unresolvable claim on #{artifact} in " \
|
|
153
|
-
"intent #{Bridge.intent_id_from_dir(dir)} yielded; write " \
|
|
154
|
-
"proceeds (see /plastic-lock status)"
|
|
155
|
-
end
|
|
156
|
-
|
|
157
|
-
Claim.heartbeat(dir, artifact, session: session) rescue nil
|
|
158
|
-
end
|
|
159
|
-
rescue StandardError
|
|
160
|
-
# ignore: a claim-gate bug must never block a write (fail open)
|
|
161
|
-
end
|
|
162
|
-
|
|
163
|
-
return ALLOW
|
|
164
|
-
end
|
|
165
|
-
|
|
166
|
-
deny_json(reason)
|
|
167
|
-
end
|
|
168
|
-
|
|
169
|
-
def deny_json(reason)
|
|
170
|
-
Deny.new(shape: :json, stdout: JSON.generate(
|
|
171
|
-
"hookSpecificOutput" => {
|
|
172
|
-
"hookEventName" => "PreToolUse",
|
|
173
|
-
"permissionDecision" => "deny",
|
|
174
|
-
"permissionDecisionReason" => reason,
|
|
175
|
-
}
|
|
176
|
-
))
|
|
177
|
-
end
|
|
178
|
-
|
|
179
|
-
# --- code-gate (intents 27, 73c2, 150): stage rule OR worktree rule, first match wins.
|
|
180
|
-
def code_gate(ctx)
|
|
181
|
-
file_path = ctx.gate_path
|
|
182
|
-
return ALLOW if file_path.nil? || file_path.empty?
|
|
183
|
-
|
|
184
|
-
session = ctx.session
|
|
185
|
-
# Auditable escape (intent 150): mirrors the old Bash shim's independent
|
|
186
|
-
# ti["content"] || ti["new_string"] || "" computation for ARGV[2]. Kept
|
|
187
|
-
# local to code-gate; links-gate and create-gate never read it.
|
|
188
|
-
new_content = ctx.content || ctx.new_string || ""
|
|
189
|
-
|
|
190
|
-
if new_content && Bridge::PLASTIC_OK_RE.match?(new_content.chomp)
|
|
191
|
-
log_escape(session, file_path)
|
|
192
|
-
return ALLOW
|
|
193
|
-
end
|
|
194
|
-
|
|
195
|
-
bridge_data = Bridge.discover_bridge(session: session, cwd: file_path, edited_path: file_path)
|
|
196
|
-
return ALLOW unless bridge_data
|
|
197
|
-
|
|
198
|
-
reason = Bridge.code_gate_decision(bridge_data, file_path) ||
|
|
199
|
-
Bridge.worktree_gate_decision(bridge_data, file_path, current_session: session)
|
|
200
|
-
return ALLOW unless reason
|
|
201
|
-
|
|
202
|
-
Deny.new(shape: :stderr, lines: ["PLASTIC GATE — #{reason}"])
|
|
203
|
-
end
|
|
204
|
-
|
|
205
|
-
def log_escape(session, file_path)
|
|
206
|
-
require "fileutils"
|
|
207
|
-
log = File.join(Dir.home, ".plastic", ".cache", "gate-escapes.log")
|
|
208
|
-
FileUtils.mkdir_p(File.dirname(log))
|
|
209
|
-
File.open(log, "a") do |io|
|
|
210
|
-
io.puts("#{Time.now.utc.iso8601}\t#{session}\t#{file_path}")
|
|
211
|
-
end
|
|
212
|
-
rescue StandardError
|
|
213
|
-
# the escape still applies; logging is best-effort
|
|
214
|
-
end
|
|
215
|
-
|
|
216
|
-
# --- block log (intent 229): one TSV line per block, six tab-separated fields
|
|
217
|
-
# in a fixed order. Mirrors log_escape above exactly: best-effort, wrapped in
|
|
218
|
-
# rescue StandardError, so a logging failure can never change what a gate
|
|
219
|
-
# decided and can never raise. `path` is the injected seam a test uses to
|
|
220
|
-
# force the write to fail without eval, ENV, or a global.
|
|
221
|
-
def block_log_path
|
|
222
|
-
File.join(Dir.home, ".plastic", ".cache", "gate-blocks.log")
|
|
223
|
-
end
|
|
224
|
-
|
|
225
|
-
def log_block(gate:, session:, intent:, subject:, rule:, path: nil)
|
|
226
|
-
require "fileutils"
|
|
227
|
-
log = path || block_log_path
|
|
228
|
-
FileUtils.mkdir_p(File.dirname(log))
|
|
229
|
-
File.open(log, "a") do |io|
|
|
230
|
-
io.puts([Time.now.utc.iso8601, gate, session.to_s, intent.to_s,
|
|
231
|
-
collapse_ws(subject), collapse_ws(rule)].join("\t"))
|
|
232
|
-
end
|
|
233
|
-
rescue StandardError
|
|
234
|
-
# the block still applies; logging is best-effort
|
|
235
|
-
end
|
|
236
|
-
|
|
237
|
-
def collapse_ws(value)
|
|
238
|
-
value.to_s.gsub(/\s+/, " ").strip
|
|
239
|
-
end
|
|
240
|
-
|
|
241
|
-
# The reason text a Deny carries, per shape. Rescues to "" so a malformed
|
|
242
|
-
# outcome costs one field, never the line and never the decision.
|
|
243
|
-
def deny_reason(outcome)
|
|
244
|
-
if outcome.shape == :json
|
|
245
|
-
JSON.parse(outcome.stdout.to_s).dig("hookSpecificOutput", "permissionDecisionReason").to_s
|
|
246
|
-
else
|
|
247
|
-
Array(outcome.lines).join(" ")
|
|
248
|
-
end
|
|
249
|
-
rescue StandardError
|
|
250
|
-
""
|
|
251
|
-
end
|
|
252
|
-
|
|
253
|
-
# "" when the path is not inside an intent dir, keeping the column count fixed.
|
|
254
|
-
def block_intent_id(path)
|
|
255
|
-
dir = Bridge.intent_dir_for(path.to_s)
|
|
256
|
-
dir ? Bridge.intent_id_from_dir(dir).to_s : ""
|
|
257
|
-
rescue StandardError
|
|
258
|
-
""
|
|
259
|
-
end
|
|
260
|
-
|
|
261
|
-
# --- links-gate (intent 192): the write-time belt for the ## Links contract.
|
|
262
|
-
def links_gate(ctx)
|
|
263
|
-
path = ctx.file_path
|
|
264
|
-
return ALLOW if path.to_s.strip.empty?
|
|
265
|
-
|
|
266
|
-
abs = File.expand_path(path)
|
|
267
|
-
return ALLOW unless LinksGate.intent_file?(abs)
|
|
268
|
-
|
|
269
|
-
content = ctx.content_or_nil
|
|
270
|
-
old_string = ctx.old_string
|
|
271
|
-
|
|
272
|
-
before_content = File.exist?(abs) ? File.read(abs) : ""
|
|
273
|
-
|
|
274
|
-
after_content =
|
|
275
|
-
if !content.nil?
|
|
276
|
-
content
|
|
277
|
-
elsif !old_string.nil?
|
|
278
|
-
return ALLOW unless before_content.include?(old_string)
|
|
279
|
-
ctx.replace_all ? before_content.gsub(old_string, ctx.new_string.to_s)
|
|
280
|
-
: before_content.sub(old_string, ctx.new_string.to_s)
|
|
281
|
-
else
|
|
282
|
-
return ALLOW # pathless mutation (no visible proposal); cannot judge, allow
|
|
283
|
-
end
|
|
284
|
-
|
|
285
|
-
plastic_home = ENV.fetch("PLASTIC_HOME") { File.join(Dir.home, ".plastic") }
|
|
286
|
-
|
|
287
|
-
reason = LinksGate.decision(file_path: abs, before_content: before_content,
|
|
288
|
-
after_content: after_content, plastic_home: plastic_home)
|
|
289
|
-
return ALLOW unless reason
|
|
290
|
-
|
|
291
|
-
Deny.new(shape: :stderr, lines: [reason])
|
|
292
|
-
end
|
|
293
|
-
|
|
294
|
-
# --- create-gate (intents 60b, 108 D7): validate the PROPOSED intent file content.
|
|
295
|
-
def create_gate(ctx)
|
|
296
|
-
path = ctx.file_path
|
|
297
|
-
return ALLOW if path.nil? || path.to_s.strip.empty?
|
|
298
|
-
|
|
299
|
-
abs = File.expand_path(path)
|
|
300
|
-
dir = File.dirname(abs)
|
|
301
|
-
is_intent_file = dir.match?(%r{/store/[^/]+--[^/]+\z}) &&
|
|
302
|
-
File.basename(abs) == "#{File.basename(dir)}.md"
|
|
303
|
-
return ALLOW unless is_intent_file
|
|
304
|
-
|
|
305
|
-
content = ctx.content_or_nil
|
|
306
|
-
old_string = ctx.old_string
|
|
307
|
-
|
|
308
|
-
result =
|
|
309
|
-
if !content.nil?
|
|
310
|
-
IntentValidator.validate_content(content)
|
|
311
|
-
elsif !old_string.nil?
|
|
312
|
-
unless File.exist?(abs)
|
|
313
|
-
return Deny.new(shape: :stderr, lines: [
|
|
314
|
-
"PLASTIC CREATE GATE — #{File.basename(abs)} does not exist; " \
|
|
315
|
-
"create intents via new-intent / plastic-intent-creating.",
|
|
316
|
-
])
|
|
317
|
-
end
|
|
318
|
-
current = File.read(abs)
|
|
319
|
-
return ALLOW unless current.include?(old_string)
|
|
320
|
-
simulated = ctx.replace_all ? current.gsub(old_string, ctx.new_string.to_s)
|
|
321
|
-
: current.sub(old_string, ctx.new_string.to_s)
|
|
322
|
-
IntentValidator.validate_content(simulated)
|
|
323
|
-
else
|
|
324
|
-
unless File.exist?(abs)
|
|
325
|
-
return Deny.new(shape: :stderr, lines: [
|
|
326
|
-
"PLASTIC CREATE GATE — #{File.basename(abs)}: cannot read proposed " \
|
|
327
|
-
"content and no file exists; refusing to allow an unvalidated intent write.",
|
|
328
|
-
])
|
|
329
|
-
end
|
|
330
|
-
IntentValidator.validate_content(File.read(abs))
|
|
331
|
-
end
|
|
332
|
-
|
|
333
|
-
return ALLOW if result[:ok]
|
|
334
|
-
|
|
335
|
-
lines = ["PLASTIC CREATE GATE — #{File.basename(abs)} is not a valid intent:"]
|
|
336
|
-
result[:errors].each { |e| lines << " #{e}" }
|
|
337
|
-
lines << "Create intents via new-intent / plastic-intent-creating; do not hand-author them."
|
|
338
|
-
Deny.new(shape: :stderr, lines: lines)
|
|
339
|
-
end
|
|
340
|
-
|
|
341
|
-
# Applicability (post-review fixes 1+2, intent 244): reproduces the Claude
|
|
342
|
-
# harness's OWN matcher semantics, not the dispatcher's earlier string
|
|
343
|
-
# equality. Two corrections:
|
|
344
|
-
# - the harness matches an UNANCHORED REGEX (`"Write|Edit"` matches
|
|
345
|
-
# "NotebookEdit" by substring), so applicability must too, or a gate
|
|
346
|
-
# whose table entry never listed NotebookEdit explicitly stops firing on
|
|
347
|
-
# it even though the old per-gate matcher group DID fire (a coverage
|
|
348
|
-
# narrowing the byte-identical-behavior bar exists to catch);
|
|
349
|
-
# - a missing/unrecognized tool_name means "the harness matched, but did
|
|
350
|
-
# not name the tool", not "not our tool": the five old wrappers never
|
|
351
|
-
# read tool_name at all, so they ALL ran on such a payload. Skipping
|
|
352
|
-
# every gate here would be a coverage WEAKENING (the gate-weakening
|
|
353
|
-
# direction is the dangerous one; see intent 203).
|
|
354
|
-
def tool_applies?(tool_name, tools)
|
|
355
|
-
return true if tool_name.to_s.empty?
|
|
356
|
-
Regexp.new(tools.join("|")).match?(tool_name.to_s)
|
|
357
|
-
end
|
|
358
|
-
|
|
359
|
-
# --- the ordered evaluation (spec D-a, D-i) -------------------------------
|
|
360
|
-
# `route` is injected so a test can supply a raising gate and prove isolation
|
|
361
|
-
# without eval, an ENV seam, or a global. `gate_tools` is injected for the same
|
|
362
|
-
# reason and defaults to the single source of truth.
|
|
363
|
-
# Returns the process exit code.
|
|
364
|
-
def dispatch(ctx:, route:, gate_tools: HookRegistry::GATE_TOOLS, out: $stdout, err: $stderr,
|
|
365
|
-
block_log: nil)
|
|
366
|
-
gate_tools.each do |gate, tools|
|
|
367
|
-
next unless tool_applies?(ctx.tool_name, tools)
|
|
368
|
-
|
|
369
|
-
begin
|
|
370
|
-
outcome = route.call(gate, ctx)
|
|
371
|
-
next unless outcome
|
|
372
|
-
|
|
373
|
-
# intent 229: one block-log line per deny, both shapes, one call site.
|
|
374
|
-
# Every helper here rescues internally, so nothing new can reach this
|
|
375
|
-
# method's rescue and turn a logging problem into a changed decision.
|
|
376
|
-
subject = ctx.gate_path.to_s.empty? ? ctx.file_path.to_s : ctx.gate_path.to_s
|
|
377
|
-
log_block(gate: gate, session: ctx.session, intent: block_intent_id(subject),
|
|
378
|
-
subject: subject, rule: deny_reason(outcome), path: block_log)
|
|
379
|
-
|
|
380
|
-
if outcome.shape == :json
|
|
381
|
-
out.print outcome.stdout
|
|
382
|
-
return 0
|
|
383
|
-
else
|
|
384
|
-
outcome.lines.each { |line| err.puts line }
|
|
385
|
-
return 2
|
|
386
|
-
end
|
|
387
|
-
rescue StandardError => e
|
|
388
|
-
# Fix 7: outcome.shape / out.print / err.puts now live INSIDE the same
|
|
389
|
-
# rescue as route.call, so a malformed outcome or a stream write
|
|
390
|
-
# failure (e.g. EPIPE) is isolated exactly like a raising gate, and
|
|
391
|
-
# never aborts the gates still to come.
|
|
392
|
-
err.puts "plastic #{gate} error: #{e.message}"
|
|
393
|
-
next
|
|
394
|
-
end
|
|
395
|
-
end
|
|
396
|
-
0
|
|
397
|
-
end
|
|
398
|
-
end
|
|
@@ -1,140 +0,0 @@
|
|
|
1
|
-
# encoding: UTF-8
|
|
2
|
-
# frozen_string_literal: true
|
|
3
|
-
|
|
4
|
-
require_relative "intent_validator"
|
|
5
|
-
require_relative "links_section"
|
|
6
|
-
require_relative "links_projection"
|
|
7
|
-
require_relative "store_discovery"
|
|
8
|
-
require_relative "graph_rebuild"
|
|
9
|
-
|
|
10
|
-
# LinksGate, the write-time belt for the PLASTIC.md `## Links` contract
|
|
11
|
-
# (intent 192). Pure decision logic plus a small store-scanning glue (mirrors
|
|
12
|
-
# the glue ProjectLinks and Doctor each already carry independently for
|
|
13
|
-
# themselves; the CALCULATION is shared via LinksSection/LinksProjection so
|
|
14
|
-
# gate, projector, and doctor can never disagree by construction, even though
|
|
15
|
-
# each IO shell still does its own discovery, exactly as project-links and
|
|
16
|
-
# doctor already do today).
|
|
17
|
-
#
|
|
18
|
-
# #decision is the single entry point the PreToolUse hook
|
|
19
|
-
# (scripts/hook-links-gate) calls: given a file path and its BEFORE/AFTER
|
|
20
|
-
# content (BEFORE = on-disk, AFTER = the proposed Write/Edit result), it
|
|
21
|
-
# returns nil (allow) or a deny message (String). It only ever judges the
|
|
22
|
-
# REAL, fence-aware `## Links` section (LinksSection.extract_section); an
|
|
23
|
-
# edit that leaves that section untouched is always allowed, cheaply, with no
|
|
24
|
-
# store scan at all.
|
|
25
|
-
module LinksGate
|
|
26
|
-
module_function
|
|
27
|
-
|
|
28
|
-
DENY_MESSAGE =
|
|
29
|
-
"PLASTIC LINKS GATE - a ## Links line must come from the frontmatter " \
|
|
30
|
-
"sources/chain graph, never be hand-typed. This edit changes the ## Links " \
|
|
31
|
-
"section to something other than its frontmatter projection. Add the edge " \
|
|
32
|
-
"to sources or chain in frontmatter instead, then run scripts/project-links " \
|
|
33
|
-
"to regenerate ## Links.".freeze
|
|
34
|
-
|
|
35
|
-
# True iff `path` is an intent file inside its own equally-named store
|
|
36
|
-
# directory (store/<id>--<slug>/<id>--<slug>.md). Mirrors the create-gate
|
|
37
|
-
# path matcher (intent 60b) so the two gates agree on what "an intent file" is.
|
|
38
|
-
def intent_file?(path)
|
|
39
|
-
return false if path.to_s.strip.empty?
|
|
40
|
-
|
|
41
|
-
abs = File.expand_path(path)
|
|
42
|
-
dir = File.dirname(abs)
|
|
43
|
-
dir.match?(%r{/store/[^/]+--[^/]+\z}) && File.basename(abs) == "#{File.basename(dir)}.md"
|
|
44
|
-
end
|
|
45
|
-
|
|
46
|
-
# Decide whether to deny a Write/Edit whose proposed result is
|
|
47
|
-
# `after_content` (the on-disk content before the edit is `before_content`,
|
|
48
|
-
# "" when the file does not yet exist). Returns nil (allow, including every
|
|
49
|
-
# "cannot judge" case) or DENY_MESSAGE.
|
|
50
|
-
def decision(file_path:, before_content:, after_content:, plastic_home:)
|
|
51
|
-
return nil unless intent_file?(file_path)
|
|
52
|
-
|
|
53
|
-
before_links = safe_extract(before_content)
|
|
54
|
-
after_links = safe_extract(after_content)
|
|
55
|
-
return nil if before_links.nil? || after_links.nil? # ambiguous ## Links; cannot judge
|
|
56
|
-
return nil if before_links == after_links # this edit does not touch ## Links at all
|
|
57
|
-
|
|
58
|
-
fm = IntentValidator.parse_frontmatter_text(after_content.to_s)
|
|
59
|
-
return nil unless fm.is_a?(Hash) # no parseable frontmatter; cannot judge
|
|
60
|
-
|
|
61
|
-
referer_store = store_key_for(file_path, plastic_home)
|
|
62
|
-
return nil unless referer_store # not under any discovered store; cannot judge
|
|
63
|
-
|
|
64
|
-
ctx = build_context(plastic_home)
|
|
65
|
-
resolve = ->(ref) do
|
|
66
|
-
LinksProjection.resolve_ref_projection(
|
|
67
|
-
ref, referer_store: referer_store, relocation_map: ctx[:relocation_map],
|
|
68
|
-
store_index: ctx[:store_index], node_index: ctx[:node_index]
|
|
69
|
-
)
|
|
70
|
-
end
|
|
71
|
-
|
|
72
|
-
expected =
|
|
73
|
-
begin
|
|
74
|
-
LinksProjection.section(sources: Array(fm["sources"]), chain: Array(fm["chain"]),
|
|
75
|
-
resolve: resolve)
|
|
76
|
-
rescue LinksProjection::UnresolvedRef
|
|
77
|
-
return nil # a pre-existing dead frontmatter ref is a doctor finding, not this gate's job
|
|
78
|
-
end
|
|
79
|
-
|
|
80
|
-
after_links == expected ? nil : DENY_MESSAGE
|
|
81
|
-
end
|
|
82
|
-
|
|
83
|
-
# Fence-aware real-section extract, tolerant of an ambiguous file (returns
|
|
84
|
-
# nil rather than raising, so #decision can fail open on it).
|
|
85
|
-
def safe_extract(content)
|
|
86
|
-
LinksSection.extract_section(IntentValidator.body_of(content.to_s))
|
|
87
|
-
rescue LinksSection::AmbiguousLinks
|
|
88
|
-
nil
|
|
89
|
-
end
|
|
90
|
-
|
|
91
|
-
# Which discovered store (by store_index/node_index key) `file_path` lives
|
|
92
|
-
# under, or nil when it is not inside any store this plastic_home discovers.
|
|
93
|
-
def store_key_for(file_path, plastic_home)
|
|
94
|
-
abs = File.expand_path(file_path)
|
|
95
|
-
store_dir = File.dirname(File.dirname(abs)) # .../<store>/<id>--<slug>/<file>.md
|
|
96
|
-
StoreDiscovery.discover(plastic_home)[:stores]
|
|
97
|
-
.find { |s| File.expand_path(s[:store]) == store_dir }
|
|
98
|
-
&.fetch(:key)
|
|
99
|
-
end
|
|
100
|
-
|
|
101
|
-
# Build the store_index/node_index/relocation_map resolver context spanning
|
|
102
|
-
# every discovered store, the same shape ProjectLinks#run and Doctor each
|
|
103
|
-
# build for themselves. Re-scanned per call (no caching): this only runs on
|
|
104
|
-
# the rare edit that actually changes ## Links, so the cost is paid where it
|
|
105
|
-
# matters, not on every Edit/Write.
|
|
106
|
-
def build_context(plastic_home)
|
|
107
|
-
discovery = StoreDiscovery.discover(plastic_home)
|
|
108
|
-
store_index = {}
|
|
109
|
-
node_index = {}
|
|
110
|
-
index_texts = {}
|
|
111
|
-
|
|
112
|
-
discovery[:stores].each do |s|
|
|
113
|
-
nodes = load_nodes(s[:store])
|
|
114
|
-
store_index[s[:key]] = nodes.keys
|
|
115
|
-
node_index[s[:key]] = nodes.transform_values { |v| { basename: v[:basename], label: v[:label] } }
|
|
116
|
-
index_texts[s[:key]] = File.exist?(s[:index]) ? File.read(s[:index]) : ""
|
|
117
|
-
end
|
|
118
|
-
|
|
119
|
-
{ store_index: store_index, node_index: node_index,
|
|
120
|
-
relocation_map: GraphRebuild.build_relocation_map(index_texts) }
|
|
121
|
-
end
|
|
122
|
-
|
|
123
|
-
# { id => { basename:, label: } } for one store directory.
|
|
124
|
-
def load_nodes(store_dir)
|
|
125
|
-
nodes = {}
|
|
126
|
-
Dir.children(store_dir).reject { |e| e.start_with?(".") }.sort.each do |entry|
|
|
127
|
-
dir = File.join(store_dir, entry)
|
|
128
|
-
next unless File.directory?(dir)
|
|
129
|
-
|
|
130
|
-
md = File.join(dir, "#{entry}.md")
|
|
131
|
-
next unless File.exist?(md)
|
|
132
|
-
|
|
133
|
-
fm = IntentValidator.parse_frontmatter(md)
|
|
134
|
-
next unless fm.is_a?(Hash) && fm["id"]
|
|
135
|
-
|
|
136
|
-
nodes[fm["id"].to_s] = { basename: entry, label: fm["intent"].to_s.strip }
|
|
137
|
-
end
|
|
138
|
-
nodes
|
|
139
|
-
end
|
|
140
|
-
end
|
package/scripts/lib/qmd_hook.rb
DELETED
|
@@ -1,41 +0,0 @@
|
|
|
1
|
-
# encoding: UTF-8
|
|
2
|
-
# frozen_string_literal: true
|
|
3
|
-
|
|
4
|
-
require_relative "qmd_sync"
|
|
5
|
-
require_relative "power_tools"
|
|
6
|
-
|
|
7
|
-
# QmdHook - decision logic for the power-tools UserPromptSubmit hook (intents 66,
|
|
8
|
-
# 66b, 187, 246). Pure and dependency-injected: returns the additionalContext
|
|
9
|
-
# string to emit, or nil to emit nothing. The executable hook wires real deps and
|
|
10
|
-
# prints; this is unit-tested with fake detectors (no real qmd, no network, no
|
|
11
|
-
# subprocess at all).
|
|
12
|
-
#
|
|
13
|
-
# It emits exactly one thing: the PowerTools mandate, one recommendation line per
|
|
14
|
-
# present tool (qmd for finding intents, Enola-first for code navigation, falling
|
|
15
|
-
# back to Serena; intent 187 added the enola_detector alongside the pre-existing
|
|
16
|
-
# serena_detector).
|
|
17
|
-
#
|
|
18
|
-
# Intent 246 removed the scored qmd hit injection this hook used to prepend.
|
|
19
|
-
# Intent 225 measured that injection at 0.24 intent-level recall@3 against a plain
|
|
20
|
-
# ripgrep control at 0.18, while agent-driven `qmd query` scored 0.71. The failure
|
|
21
|
-
# was recall, not latency, so caching and async were both rejected. QmdSync.search
|
|
22
|
-
# is untouched and still backs the `scripts/qmd-sync search` CLI verb. All three
|
|
23
|
-
# detectors are PATH and marker-file walks with no subprocess, which is why what is
|
|
24
|
-
# left costs about a tenth of a second.
|
|
25
|
-
module QmdHook
|
|
26
|
-
module_function
|
|
27
|
-
|
|
28
|
-
def run(cwd:, detector: QmdSync.method(:detect), serena_detector: nil,
|
|
29
|
-
enola_detector: nil)
|
|
30
|
-
serena_detector ||= -> { PowerTools.serena?(cwd: cwd) }
|
|
31
|
-
enola_detector ||= -> { PowerTools.enola?(cwd: cwd) }
|
|
32
|
-
qmd_present = !!detector.call
|
|
33
|
-
serena_present = !!serena_detector.call
|
|
34
|
-
enola_present = !!enola_detector.call
|
|
35
|
-
return nil unless qmd_present || serena_present || enola_present
|
|
36
|
-
|
|
37
|
-
PowerTools.mandate(cwd: cwd, qmd_detector: -> { qmd_present },
|
|
38
|
-
serena_detector: -> { serena_present },
|
|
39
|
-
enola_detector: -> { enola_present })
|
|
40
|
-
end
|
|
41
|
-
end
|
|
@@ -1,83 +0,0 @@
|
|
|
1
|
-
# encoding: UTF-8
|
|
2
|
-
# frozen_string_literal: true
|
|
3
|
-
|
|
4
|
-
# SpecHeader - the single implementation that parses the `Tier:` and `Settled:` lines out
|
|
5
|
-
# of a spec.md's header block (intent 213). No script, skill, or hook re-implements this
|
|
6
|
-
# grammar anywhere else; Bridge.savepoint_tier delegates here instead of carrying its own
|
|
7
|
-
# copy of the Tier regex.
|
|
8
|
-
#
|
|
9
|
-
# Grammar (both lines live above the `# ` level-1 heading):
|
|
10
|
-
#
|
|
11
|
-
# Tier: L
|
|
12
|
-
# Settled: yes (design fixed by the 2026-07-18 Fable advisor verdict)
|
|
13
|
-
#
|
|
14
|
-
# `Tier:` is exactly one of S, M, L; anything else is unparseable and yields nil. An ABSENT
|
|
15
|
-
# `Settled:` line means not settled, there is no `Settled: no` variant. The parenthesised
|
|
16
|
-
# reason is REQUIRED for a line to count as settled: a bare `Settled: yes` with no reason
|
|
17
|
-
# does NOT parse as settled. D2 calls Settled a ONE-WAY DOOR (leniency accepted now can
|
|
18
|
-
# never be tightened later), so this stays strict from the start rather than being loosened
|
|
19
|
-
# once and then needing a breaking change to fix.
|
|
20
|
-
module SpecHeader
|
|
21
|
-
module_function
|
|
22
|
-
|
|
23
|
-
# Mirrors Bridge::PLACEHOLDER_SENTINEL (scripts/lib/bridge.rb:22). Not required in from
|
|
24
|
-
# bridge.rb to avoid a require cycle: bridge.rb requires spec_header.rb, so spec_header.rb
|
|
25
|
-
# must have zero require_relative dependencies of its own.
|
|
26
|
-
PLACEHOLDER_SENTINEL = "<!-- plastic:placeholder -->"
|
|
27
|
-
|
|
28
|
-
HEADER_BLOCK_MAX_LINES = 10
|
|
29
|
-
|
|
30
|
-
TIER_RE = /\ATier:\s*(S|M|L)\z/
|
|
31
|
-
SETTLED_RE = /\ASettled:\s*yes\s*\((.*)\)\z/
|
|
32
|
-
|
|
33
|
-
# PURE. Parse a spec.md's raw text. Returns a Hash with symbol keys, always the same
|
|
34
|
-
# three keys: tier ("S"|"M"|"L"|nil), settled (true|false), settled_reason (String|nil).
|
|
35
|
-
def parse(text)
|
|
36
|
-
result = { tier: nil, settled: false, settled_reason: nil }
|
|
37
|
-
return result if text.nil?
|
|
38
|
-
|
|
39
|
-
lines = text.each_line.first(HEADER_BLOCK_MAX_LINES)
|
|
40
|
-
lines.each do |raw|
|
|
41
|
-
line = raw.chomp.strip
|
|
42
|
-
next if line.empty?
|
|
43
|
-
next if line == PLACEHOLDER_SENTINEL
|
|
44
|
-
break if line.start_with?("# ")
|
|
45
|
-
|
|
46
|
-
if (m = line.match(TIER_RE))
|
|
47
|
-
result[:tier] = m[1]
|
|
48
|
-
elsif (m = line.match(SETTLED_RE))
|
|
49
|
-
result[:settled] = true
|
|
50
|
-
result[:settled_reason] = m[1]
|
|
51
|
-
end
|
|
52
|
-
end
|
|
53
|
-
|
|
54
|
-
result
|
|
55
|
-
end
|
|
56
|
-
|
|
57
|
-
# Read at most the header block off disk and parse it. Returns the same Hash shape as
|
|
58
|
-
# `parse`, with every value nil/false when the path does not exist or cannot be read.
|
|
59
|
-
def parse_file(path)
|
|
60
|
-
lines = []
|
|
61
|
-
File.open(path) do |f|
|
|
62
|
-
HEADER_BLOCK_MAX_LINES.times do
|
|
63
|
-
line = f.gets
|
|
64
|
-
break if line.nil?
|
|
65
|
-
lines << line
|
|
66
|
-
end
|
|
67
|
-
end
|
|
68
|
-
parse(lines.join)
|
|
69
|
-
rescue StandardError
|
|
70
|
-
{ tier: nil, settled: false, settled_reason: nil }
|
|
71
|
-
end
|
|
72
|
-
|
|
73
|
-
# Render the two header lines for a spec.md. `tier` is "S"|"M"|"L" or nil, `settled_reason`
|
|
74
|
-
# is a String or nil. Returns a String ending in one newline. A nil tier renders the
|
|
75
|
-
# placeholder `Tier: S|M|L`; a nil reason renders no Settled line at all (absent means not
|
|
76
|
-
# settled).
|
|
77
|
-
def render(tier: nil, settled_reason: nil)
|
|
78
|
-
lines = []
|
|
79
|
-
lines << "Tier: #{tier || 'S|M|L'}"
|
|
80
|
-
lines << "Settled: yes (#{settled_reason})" if settled_reason
|
|
81
|
-
"#{lines.join("\n")}\n"
|
|
82
|
-
end
|
|
83
|
-
end
|