@zalom/plastic 1.0.0-beta.11 → 1.0.0-beta.13
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 +71 -0
- package/hooks/hooks.json +20 -0
- package/hooks/retrieval-gate +10 -0
- package/hooks/savepoint-pre +10 -0
- package/package.json +1 -1
- package/scripts/hook-code-gate +9 -4
- package/scripts/hook-gate-check +13 -22
- package/scripts/hook-retrieval-gate +122 -0
- package/scripts/hook-savepoint-pre +32 -0
- package/scripts/lib/bridge.rb +281 -6
- package/scripts/lib/installer_core.rb +13 -0
- package/scripts/lib/qmd_sync.rb +15 -0
- package/scripts/lib/retrieval_gate.rb +238 -0
- package/scripts/lib/worktree.rb +409 -0
- package/scripts/new-intent +9 -1
- package/scripts/spawn-preamble +4 -2
- package/skills/auto/SKILL.md +34 -2
- package/skills/auto/references/agent-report-contract.md +8 -0
- package/skills/continuing/SKILL.md +21 -6
- package/skills/intent-curator/SKILL.md +4 -1
- package/skills/managing-index/SKILL.md +2 -0
- package/skills/releasing/SKILL.md +32 -0
package/PLASTIC.md
CHANGED
|
@@ -183,6 +183,10 @@ Sections: `## Active`, `## Future`, `## Clusters`, `## Abandoned`, `## Completed
|
|
|
183
183
|
|
|
184
184
|
For index maintenance, use `plastic-managing-index`.
|
|
185
185
|
|
|
186
|
+
One-line entry convention. Each index entry is ONE line: `- [<id> <terse title>](<dir>) <tags>`.
|
|
187
|
+
The title is the title, not a summary: aim for about 80 characters, no multi-sentence
|
|
188
|
+
descriptions. This is a self-check, not a gate.
|
|
189
|
+
|
|
186
190
|
## Rules for Skills
|
|
187
191
|
|
|
188
192
|
ALL work flows through intents.
|
|
@@ -194,6 +198,53 @@ ALL work flows through intents.
|
|
|
194
198
|
5. Researches are intents. No separate folder.
|
|
195
199
|
6. Intents are created only via `plastic-creating-intent`. Never hand-author an intent file. The skill self-verifies the written intent with `scripts/validate-intent` before announcing or committing, so every intent is born complete.
|
|
196
200
|
|
|
201
|
+
## House Style (self-check)
|
|
202
|
+
|
|
203
|
+
The agent is the heaviest contributor to the transcript, so terseness pays every turn. These
|
|
204
|
+
are pre-send self-checks the agent applies to its own output. They are not gated.
|
|
205
|
+
|
|
206
|
+
- Answer or decision first. Lead with the result, then support it.
|
|
207
|
+
- Bullets over paragraphs.
|
|
208
|
+
- No preamble, no end-recap. Do not restate the question or summarize what you just said.
|
|
209
|
+
- One question-cluster at a time when asking the human.
|
|
210
|
+
- Reasoning goes in the thinking channel, not duplicated into the visible reply. This keeps
|
|
211
|
+
the human's visibility into your reasoning without paying for it twice in the transcript.
|
|
212
|
+
|
|
213
|
+
Active-intent cache rule. For the intent under active development you already hold its
|
|
214
|
+
delivered artifacts in your own context: prefer revisiting that in-context memory (hit the
|
|
215
|
+
cache) over re-reading them from disk, which only widens context. QMD is for OTHER or indexed
|
|
216
|
+
intents, not for re-reading what you just wrote. Pairs with `/clear` plus savepoint-resume
|
|
217
|
+
hygiene after each intent. Advisory self-check, not hard-verifiable.
|
|
218
|
+
|
|
219
|
+
## Retrieval Gate
|
|
220
|
+
|
|
221
|
+
A single capability-aware PreToolUse gate enforces retrieval-first routing on the agent's own
|
|
222
|
+
Bash/Read/Grep/Glob calls (and on subagents, since PreToolUse binds them). Detection is binary:
|
|
223
|
+
present means enforce, absent or down means off, with no warning and no advisory tier.
|
|
224
|
+
|
|
225
|
+
- Store markdown (under a Plastic store) routes to QMD when QMD is present and the index is
|
|
226
|
+
fresh: the raw grep/find/Read is blocked and you use `qmd search`/`qmd query` (or
|
|
227
|
+
`scripts/qmd-sync search`) instead. When QMD is present but stale, the read is allowed this
|
|
228
|
+
turn and a background reindex is fired so the next turn enforces against a fresh index;
|
|
229
|
+
reindex is never synchronous. When QMD is absent or down, raw reads are allowed.
|
|
230
|
+
- Serena-supported code and data files route to Serena symbolic tools when Serena is present;
|
|
231
|
+
absent means allowed.
|
|
232
|
+
- Images, binaries, and everything else are allowed.
|
|
233
|
+
- Bypass: append a trailing `# qmd-ok` shell comment to a Bash command for the rare case where
|
|
234
|
+
QMD is healthy but you genuinely need the raw read. A quoted or echoed occurrence does not
|
|
235
|
+
bypass. Bypasses are logged.
|
|
236
|
+
- Scope: only the agent's tool calls. Ruby `File.read` inside a script is invisible to the gate
|
|
237
|
+
and is out of scope by design.
|
|
238
|
+
|
|
239
|
+
## Context-economy measurement buckets (84a)
|
|
240
|
+
|
|
241
|
+
Intent 84 defines three buckets for sibling 84a to audit against; 84 does not run the audit.
|
|
242
|
+
|
|
243
|
+
- (a) gate-hook prose tokens: the per-transition narration emitted by the gate hook.
|
|
244
|
+
- (b) main-loop store-read tokens: tokens the main agent spends reading or grepping the store
|
|
245
|
+
in the transcript.
|
|
246
|
+
- (c) authored-section sizes: sizes of authored artifacts (INDEX entries and the like).
|
|
247
|
+
|
|
197
248
|
## Transition Gates
|
|
198
249
|
|
|
199
250
|
| Transition | Trigger | Gate |
|
|
@@ -205,6 +256,26 @@ ALL work flows through intents.
|
|
|
205
256
|
|
|
206
257
|
Hard blocking — hooks exit code 2 on gate failure.
|
|
207
258
|
|
|
259
|
+
## Delivery Isolation and the Single-Owner Lock
|
|
260
|
+
|
|
261
|
+
Exactly one session or agent develops an intent's delivery at a time. Ownership is the armed
|
|
262
|
+
session bridge, which doubles as the delivery lock: arming records the owning session, the
|
|
263
|
+
owner pid, an acquired-at timestamp, and the host. Another session that finds an armed bridge
|
|
264
|
+
for the same intent with a live owner backs off; if the owner pid is dead the lock is
|
|
265
|
+
reclaimable. This is mandatory, not a convention.
|
|
266
|
+
|
|
267
|
+
Every code-touching intent gets its own git worktree named `{id}--{slug}`, and all code edits
|
|
268
|
+
for that intent happen only inside it. Plastic provisions the worktree deterministically: it
|
|
269
|
+
resolves the project repo from `projects.yml` and runs `git -C <repo> worktree add`, so
|
|
270
|
+
isolation never depends on the current working directory. There are two worktrees per project
|
|
271
|
+
intent: a code worktree at `<repo>/.claude/worktrees/{id}--{slug}` (branch `plastic/{id}--{slug}`)
|
|
272
|
+
and a store worktree at `<plastic_home>/.worktrees/{id}--{slug}` (branch
|
|
273
|
+
`plastic-store/{id}--{slug}`), so lifecycle-doc commits and code commits move as one unit.
|
|
274
|
+
|
|
275
|
+
Provisioning fails open for intents that touch no project code (pure research or decision
|
|
276
|
+
intents in the global store, or a non-git repo): those get the lock only, and the worktree
|
|
277
|
+
block stays unprovisioned. The fail-open path is always logged, never silent.
|
|
278
|
+
|
|
208
279
|
## Deprecation Process
|
|
209
280
|
|
|
210
281
|
Deprecations live in `deprecations.yml` and are shown at SessionStart. While Plastic is
|
package/hooks/hooks.json
CHANGED
|
@@ -40,6 +40,16 @@
|
|
|
40
40
|
}
|
|
41
41
|
]
|
|
42
42
|
},
|
|
43
|
+
{
|
|
44
|
+
"matcher": "Write|Edit",
|
|
45
|
+
"hooks": [
|
|
46
|
+
{
|
|
47
|
+
"type": "command",
|
|
48
|
+
"command": "\"${CLAUDE_PLUGIN_ROOT}/hooks/run-hook\" savepoint-pre",
|
|
49
|
+
"statusMessage": "Recording stage start..."
|
|
50
|
+
}
|
|
51
|
+
]
|
|
52
|
+
},
|
|
43
53
|
{
|
|
44
54
|
"matcher": "Write",
|
|
45
55
|
"hooks": [
|
|
@@ -59,6 +69,16 @@
|
|
|
59
69
|
"statusMessage": "Checking lifecycle gate..."
|
|
60
70
|
}
|
|
61
71
|
]
|
|
72
|
+
},
|
|
73
|
+
{
|
|
74
|
+
"matcher": "Bash|Read|Grep|Glob",
|
|
75
|
+
"hooks": [
|
|
76
|
+
{
|
|
77
|
+
"type": "command",
|
|
78
|
+
"command": "\"${CLAUDE_PLUGIN_ROOT}/hooks/run-hook\" retrieval-gate",
|
|
79
|
+
"statusMessage": "Checking retrieval gate..."
|
|
80
|
+
}
|
|
81
|
+
]
|
|
62
82
|
}
|
|
63
83
|
],
|
|
64
84
|
"PostToolUse": [
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
# Retrieval-gate hook launcher (intent 84, Lever 2). No-op when there is no
|
|
3
|
+
# global store. Passes the PreToolUse JSON through on stdin (exec inherits it)
|
|
4
|
+
# and the plastic_home as ARGV[0], mirroring the qmd-search launcher.
|
|
5
|
+
GLOBAL_INDEX="$HOME/.plastic/INDEX.md"
|
|
6
|
+
if [ ! -f "$GLOBAL_INDEX" ]; then
|
|
7
|
+
exit 0
|
|
8
|
+
fi
|
|
9
|
+
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
|
10
|
+
exec ruby "$SCRIPT_DIR/../scripts/hook-retrieval-gate" "$HOME/.plastic"
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
INPUT=$(cat)
|
|
3
|
+
FILE_PATH=$(echo "$INPUT" | ruby -rjson -e 'data = JSON.parse(STDIN.read); puts data.dig("tool_params", "file_path") || data.dig("tool_input", "file_path") || ""' 2>/dev/null)
|
|
4
|
+
|
|
5
|
+
if [ -z "$FILE_PATH" ]; then
|
|
6
|
+
exit 0
|
|
7
|
+
fi
|
|
8
|
+
|
|
9
|
+
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
|
10
|
+
ruby "$SCRIPT_DIR/../scripts/hook-savepoint-pre" "$FILE_PATH"
|
package/package.json
CHANGED
package/scripts/hook-code-gate
CHANGED
|
@@ -3,11 +3,15 @@
|
|
|
3
3
|
# frozen_string_literal: true
|
|
4
4
|
|
|
5
5
|
# Usage: hook-code-gate <file_path>
|
|
6
|
-
# PreToolUse gate
|
|
7
|
-
#
|
|
6
|
+
# PreToolUse gate. Composes two independent block rules; EITHER blocks the edit:
|
|
7
|
+
# - Stage rule (intent 27): when auto mode is armed and the active intent has not
|
|
8
|
+
# reached How (plan.md + checklist.md), block edits to project code outside the store.
|
|
9
|
+
# - Worktree isolation rule (intent 73c2): when the intent has a provisioned code
|
|
10
|
+
# worktree, block project-code edits outside it; and block edits to another
|
|
11
|
+
# intent's store dir whose delivery lock is held by a live non-owner session.
|
|
8
12
|
#
|
|
9
13
|
# Exit 0 = allow. Exit 2 = block (reason on stderr, shown to the agent).
|
|
10
|
-
# No bridge
|
|
14
|
+
# No bridge = allow. Each rule fails open on its own conditions (see bridge.rb).
|
|
11
15
|
|
|
12
16
|
require_relative "lib/bridge"
|
|
13
17
|
|
|
@@ -20,7 +24,8 @@ session = (ARGV[1] unless ARGV[1].to_s.empty?) || ENV["CLAUDE_SESSION_ID"]
|
|
|
20
24
|
bridge_data = Bridge.discover_bridge(session: session, cwd: Dir.pwd)
|
|
21
25
|
exit 0 unless bridge_data
|
|
22
26
|
|
|
23
|
-
reason = Bridge.code_gate_decision(bridge_data, file_path)
|
|
27
|
+
reason = Bridge.code_gate_decision(bridge_data, file_path) ||
|
|
28
|
+
Bridge.worktree_gate_decision(bridge_data, file_path, current_session: session)
|
|
24
29
|
exit 0 unless reason
|
|
25
30
|
|
|
26
31
|
$stderr.puts "PLASTIC GATE — #{reason}"
|
package/scripts/hook-gate-check
CHANGED
|
@@ -24,6 +24,12 @@ intent_dir_abs = Bridge.intent_dir_for(file_path_abs)
|
|
|
24
24
|
if intent_dir_abs
|
|
25
25
|
begin
|
|
26
26
|
Bridge.append_savepoint(intent_dir_abs, file_path_abs)
|
|
27
|
+
# When checklist.md lands, How ends and Exec begins: emit the `Exec started`
|
|
28
|
+
# companion in the same event (intent 81). Guard on a real (non-placeholder)
|
|
29
|
+
# checklist so a scaffold sentinel does not trip it.
|
|
30
|
+
if File.basename(file_path_abs) == "checklist.md" && Bridge.stage_file_present?(file_path_abs)
|
|
31
|
+
Bridge.append_exec_started(intent_dir_abs)
|
|
32
|
+
end
|
|
27
33
|
rescue StandardError
|
|
28
34
|
# ignore — rebuildable from disk
|
|
29
35
|
end
|
|
@@ -112,32 +118,17 @@ if is_stage_file || is_action_file
|
|
|
112
118
|
|
|
113
119
|
Bridge.write(session, bridge_data)
|
|
114
120
|
|
|
115
|
-
# Build transition context
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
"done" => "Exec complete. Intent must be completed now — write outcome.md, update INDEX.md, auto-commit. Use plastic-auto or do it manually."
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
context_parts = ["PLASTIC"]
|
|
125
|
-
if old_stage != new_stage
|
|
126
|
-
context_parts[0] = "PLASTIC — Stage transition: #{stage_labels[old_stage] || old_stage} → #{stage_labels[new_stage] || new_stage}."
|
|
127
|
-
else
|
|
128
|
-
context_parts[0] = "PLASTIC — #{basename} written (stage: #{stage_labels[new_stage] || new_stage})."
|
|
129
|
-
end
|
|
130
|
-
context_parts << "#{basename} written." if old_stage != new_stage
|
|
131
|
-
if new_missing.any?
|
|
132
|
-
context_parts << "Next: #{new_missing.join(", ")}"
|
|
133
|
-
elsif next_hints[new_stage]
|
|
134
|
-
context_parts << "Next: #{next_hints[new_stage]}"
|
|
135
|
-
end
|
|
121
|
+
# Build transition context — ONE concise sentence (intent 84, Lever 1),
|
|
122
|
+
# preserving the `Next: ...` hint. Formatting is pure in Bridge.gate_narration.
|
|
123
|
+
context = Bridge.gate_narration(
|
|
124
|
+
old_stage: old_stage, new_stage: new_stage,
|
|
125
|
+
basename: basename, new_missing: new_missing
|
|
126
|
+
)
|
|
136
127
|
|
|
137
128
|
payload = {
|
|
138
129
|
"hookSpecificOutput" => {
|
|
139
130
|
"hookEventName" => "PostToolUse",
|
|
140
|
-
"additionalContext" =>
|
|
131
|
+
"additionalContext" => context
|
|
141
132
|
}
|
|
142
133
|
}
|
|
143
134
|
puts JSON.generate(payload)
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# encoding: UTF-8
|
|
3
|
+
# frozen_string_literal: true
|
|
4
|
+
|
|
5
|
+
# PreToolUse retrieval gate (intent 84, Lever 2). Reads the tool call (JSON on
|
|
6
|
+
# stdin: tool_name + tool_input), computes capabilities (QMD detect+freshness,
|
|
7
|
+
# Serena detect), delegates the decision to RetrievalGate, and enforces:
|
|
8
|
+
# ALLOW = exit 0 ; BLOCK = exit 2 with reason on stderr (shown to the agent).
|
|
9
|
+
# Fail-open: any parse error, timeout, or unexpected exception exits 0. On the
|
|
10
|
+
# STALE QMD path RetrievalGate fires QmdSync.reindex_async (NEVER synchronous).
|
|
11
|
+
# Binds subagents (PreToolUse hooks apply to subagent tool calls too).
|
|
12
|
+
#
|
|
13
|
+
# Scope: only the agent's own Bash/Read/Grep/Glob calls. Ruby `File.read` inside
|
|
14
|
+
# scripts is invisible to a PreToolUse hook and is out of scope (no exemptions).
|
|
15
|
+
#
|
|
16
|
+
# ARGV[0] is plastic_home (passed by the launcher), like hook-qmd-search.
|
|
17
|
+
|
|
18
|
+
require "json"
|
|
19
|
+
require "timeout"
|
|
20
|
+
require_relative "lib/retrieval_gate"
|
|
21
|
+
require_relative "lib/qmd_sync"
|
|
22
|
+
require_relative "lib/power_tools"
|
|
23
|
+
|
|
24
|
+
module RetrievalGateHook
|
|
25
|
+
module_function
|
|
26
|
+
|
|
27
|
+
# Pure-ish core: capabilities and reindex are injected so this is unit-testable
|
|
28
|
+
# with no real qmd/serena. Returns [exit_code, stderr_string].
|
|
29
|
+
# stdin: raw PreToolUse JSON
|
|
30
|
+
# capabilities: { qmd:, qmd_fresh:, serena: }
|
|
31
|
+
# reindex: callable fired on the STALE path
|
|
32
|
+
def run(stdin:, plastic_home:, cwd:, capabilities:, reindex: -> {})
|
|
33
|
+
payload = parse(stdin)
|
|
34
|
+
return [0, nil] unless payload
|
|
35
|
+
|
|
36
|
+
tool_name = payload["tool_name"].to_s
|
|
37
|
+
tool_input = payload["tool_input"]
|
|
38
|
+
tool_input = {} unless tool_input.is_a?(Hash)
|
|
39
|
+
|
|
40
|
+
bypassed = false
|
|
41
|
+
reason = RetrievalGate.decision(
|
|
42
|
+
tool_name: tool_name, tool_input: tool_input,
|
|
43
|
+
plastic_home: plastic_home, cwd: cwd,
|
|
44
|
+
capabilities: capabilities, reindex: reindex
|
|
45
|
+
) { |_sig| bypassed = true }
|
|
46
|
+
|
|
47
|
+
if reason
|
|
48
|
+
[2, "PLASTIC GATE — #{reason}"]
|
|
49
|
+
elsif bypassed
|
|
50
|
+
[0, "PLASTIC GATE — bypassed via # qmd-ok"]
|
|
51
|
+
else
|
|
52
|
+
[0, nil]
|
|
53
|
+
end
|
|
54
|
+
rescue StandardError
|
|
55
|
+
[0, nil] # fail-open
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def parse(raw)
|
|
59
|
+
data = JSON.parse(raw.to_s)
|
|
60
|
+
data.is_a?(Hash) ? data : nil
|
|
61
|
+
rescue StandardError
|
|
62
|
+
nil
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Detect real capabilities for the live executable. A slow `qmd status` cannot
|
|
66
|
+
# stall a tool call: a Timeout around the freshness probe degrades to "absent
|
|
67
|
+
# for this turn" (allow, no reindex), staying fail-open and non-blocking.
|
|
68
|
+
def detect_capabilities(cwd:)
|
|
69
|
+
qmd = QmdSync.detect
|
|
70
|
+
qmd_fresh = false
|
|
71
|
+
if qmd
|
|
72
|
+
qmd_fresh = begin
|
|
73
|
+
Timeout.timeout(2) { QmdSync.fresh? }
|
|
74
|
+
rescue StandardError
|
|
75
|
+
# Probe stalled/failed: treat as absent this turn (allow, no reindex).
|
|
76
|
+
qmd = false
|
|
77
|
+
false
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
serena = PowerTools.serena?(cwd: cwd)
|
|
81
|
+
{ qmd: qmd, qmd_fresh: qmd_fresh, serena: serena }
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# Best-effort reindex callable for the STALE path. Resolves the collection from
|
|
85
|
+
# cwd (project + global) and fires the async reindexer for each; never raises.
|
|
86
|
+
def reindex_for(cwd:, plastic_home:)
|
|
87
|
+
lambda do
|
|
88
|
+
begin
|
|
89
|
+
cols = QmdSync.collections_for_cwd(cwd, plastic_home: plastic_home)
|
|
90
|
+
cols.each { |c| QmdSync.reindex_async(collection: c) }
|
|
91
|
+
rescue StandardError
|
|
92
|
+
# non-fatal; the read is already allowed this turn
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# --- executable entrypoint ---
|
|
99
|
+
if $PROGRAM_NAME == __FILE__
|
|
100
|
+
raw = begin
|
|
101
|
+
$stdin.read
|
|
102
|
+
rescue StandardError
|
|
103
|
+
""
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
plastic_home = (ARGV[0] && !ARGV[0].empty?) ? ARGV[0] : File.expand_path("~/.plastic")
|
|
107
|
+
cwd = Dir.pwd
|
|
108
|
+
|
|
109
|
+
code, err = begin
|
|
110
|
+
caps = RetrievalGateHook.detect_capabilities(cwd: cwd)
|
|
111
|
+
RetrievalGateHook.run(
|
|
112
|
+
stdin: raw, plastic_home: plastic_home, cwd: cwd,
|
|
113
|
+
capabilities: caps,
|
|
114
|
+
reindex: RetrievalGateHook.reindex_for(cwd: cwd, plastic_home: plastic_home)
|
|
115
|
+
)
|
|
116
|
+
rescue StandardError
|
|
117
|
+
[0, nil] # fail-open at the outermost boundary too
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
$stderr.puts(err) if err && !err.empty?
|
|
121
|
+
exit code
|
|
122
|
+
end
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# encoding: UTF-8
|
|
3
|
+
# frozen_string_literal: true
|
|
4
|
+
# Usage: hook-savepoint-pre <file_path>
|
|
5
|
+
#
|
|
6
|
+
# PreToolUse savepoint trigger (intent 81). When a stage-opening lifecycle file
|
|
7
|
+
# (spec.md => Why, plan.md => How) is ABOUT to be written into an intent dir,
|
|
8
|
+
# append the pre-stage `started` ledger line so the ledger records "this stage
|
|
9
|
+
# was entered" before its artifact lands.
|
|
10
|
+
#
|
|
11
|
+
# Like the PostToolUse decoupled savepoint write (intent 52), it is derived from
|
|
12
|
+
# the file path alone: no bridge, no session, fires headless. It NEVER blocks a
|
|
13
|
+
# write: any non-match or failure exits 0. The append is idempotent and only
|
|
14
|
+
# fires while the stage is genuinely starting (the artifact is not yet a real,
|
|
15
|
+
# non-placeholder file), so re-edits add nothing.
|
|
16
|
+
|
|
17
|
+
require_relative "lib/bridge"
|
|
18
|
+
|
|
19
|
+
file_path = ARGV[0]
|
|
20
|
+
exit 0 if file_path.nil? || file_path.empty?
|
|
21
|
+
|
|
22
|
+
file_path_abs = File.expand_path(file_path)
|
|
23
|
+
intent_dir = Bridge.intent_dir_for(file_path_abs)
|
|
24
|
+
exit 0 unless intent_dir
|
|
25
|
+
|
|
26
|
+
begin
|
|
27
|
+
Bridge.append_started_savepoint(intent_dir, file_path_abs)
|
|
28
|
+
rescue StandardError
|
|
29
|
+
# best-effort; the ledger is rebuildable and the post line still lands
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
exit 0
|