@zalom/plastic 1.9.0 → 1.10.0
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 +23 -8
- package/README.md +15 -4
- package/agents/plastic-enforcer.md +3 -2
- package/agents/plastic-intent-discovery.md +7 -0
- package/bin/plastic.js +17 -8
- package/hooks/auto-arm +2 -2
- package/hooks/bash-gate +1 -1
- package/hooks/check-update +1 -1
- package/hooks/continue +2 -2
- package/hooks/edit-gates +1 -1
- package/hooks/future-intent-check +2 -2
- package/hooks/gate-check +3 -3
- package/hooks/power-tools +1 -1
- package/hooks/session-start +1 -1
- package/package.json +1 -1
- package/scripts/codex-hook +50 -106
- package/scripts/doctor.rb +132 -1080
- package/scripts/exec-worktree +103 -0
- package/scripts/hash-intent +1 -1
- package/scripts/hook-bash-gate +19 -0
- package/scripts/hook-code-gate +4 -1
- package/scripts/hook-continue +2 -2
- package/scripts/hook-create-gate +6 -3
- package/scripts/hook-gate-check +17 -0
- package/scripts/hook-links-gate +4 -1
- package/scripts/hook-lock-gate +7 -3
- package/scripts/hook-savepoint-pre +4 -1
- package/scripts/hook-session-start +21 -15
- package/scripts/lib/apply_patch_envelope.rb +46 -13
- package/scripts/lib/bridge.rb +83 -15
- package/scripts/lib/codex_edit_gates.rb +138 -0
- package/scripts/lib/doctor_core.rb +1087 -0
- package/scripts/lib/edit_gates.rb +61 -5
- package/scripts/lib/exec_worktree.rb +325 -0
- package/scripts/lib/harness_text.rb +57 -0
- package/scripts/lib/hook_registry.rb +32 -28
- package/scripts/lib/installer_core.rb +67 -7
- package/scripts/lib/lock.rb +196 -47
- package/scripts/lib/ruby_probe.rb +60 -0
- package/scripts/lib/scaffold_intent.rb +392 -0
- package/scripts/lib/spec_header.rb +83 -0
- package/scripts/lib/start_intent.rb +296 -0
- package/scripts/lib/verify_intent.rb +262 -0
- package/scripts/lib/worktree.rb +15 -1
- package/scripts/link-suggest +1 -1
- package/scripts/maintenance-run +5 -5
- package/scripts/migrate-to-global +2 -2
- package/scripts/restore-intent-v1 +1 -1
- package/scripts/scaffold-intent +120 -0
- package/scripts/start-intent +89 -0
- package/scripts/verify-intent +73 -0
- package/skills/agent-advisor/SKILL.md +5 -5
- package/skills/auto/SKILL.md +42 -32
- package/skills/auto/references/agent-architecture.md +1 -1
- package/skills/auto/references/agent-report-contract.md +1 -1
- package/skills/auto/references/human-report-contract.md +22 -3
- package/skills/auto/references/tiers.md +24 -2
- package/skills/conventions/references/completion-and-done.md +3 -0
- package/skills/conventions/references/gates-and-enforcement.md +28 -12
- package/skills/conventions/references/locks-and-worktrees.md +3 -3
- package/skills/conventions/references/tiers-and-dispatch.md +7 -6
- package/skills/dashboard/SKILL.md +1 -1
- package/skills/doctor/SKILL.md +6 -5
- package/skills/doctor/references/gates-stuck-detection.md +13 -8
- package/skills/doctor/report.md +1 -1
- package/skills/install/SKILL.md +1 -1
- package/skills/intent-brainstorming/SKILL.md +0 -2
- package/skills/intent-creating/SKILL.md +6 -6
- package/skills/intent-creating/references/lifecycle.md +1 -1
- package/skills/intent-discovering/SKILL.md +10 -3
- package/skills/intent-ending/SKILL.md +8 -7
- package/skills/intent-executing/SKILL.md +27 -19
- package/skills/intent-grilling/SKILL.md +5 -3
- package/skills/intent-planning/SKILL.md +7 -3
- package/skills/intent-researching/SKILL.md +0 -2
- package/skills/intent-starting/SKILL.md +10 -2
- package/skills/project-creating/SKILL.md +0 -2
- package/skills/project-creating/references/project-scaffolding.md +1 -1
- package/skills/releasing/SKILL.md +1 -1
- package/skills/releasing/references/promotion-and-tagging.md +14 -8
- package/skills/releasing/references/release-lines.md +1 -1
- package/skills/skill-creating/SKILL.md +5 -2
- package/skills/store-indexing/SKILL.md +8 -5
- package/skills/store-indexing/references/zettelkasten-linking.md +1 -1
- package/skills/tutorial/references/track-1-guided.md +2 -2
- package/skills/tutorial/references/track-2-auto.md +9 -6
- package/skills/tutorial/references/track-3-projects-and-roadmaps.md +1 -1
- package/skills/uninstall/SKILL.md +6 -9
- package/templates/agents.md +12 -12
- package/templates/config.yml +6 -7
- package/templates/index.md +6 -3
- package/templates/spec.md +1 -1
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# encoding: UTF-8
|
|
3
|
+
# frozen_string_literal: true
|
|
4
|
+
|
|
5
|
+
# exec-worktree (intent 213, group 2) - finish an intent's code worktree: an order
|
|
6
|
+
# precondition, then Worktree.finish (delivered merges + removes, abandoned removes only).
|
|
7
|
+
#
|
|
8
|
+
# Usage:
|
|
9
|
+
# exec-worktree --store <path> --id <intent_id> --home <plastic_home> \
|
|
10
|
+
# --disposition delivered|abandoned [--session <session_id>]
|
|
11
|
+
#
|
|
12
|
+
# --home accepts either spelling: the OS-home level (the PARENT of .plastic) or the
|
|
13
|
+
# .plastic directory itself. When the given path's basename is literally .plastic, its
|
|
14
|
+
# dirname is used instead. Worktree.finish's home: keyword always wants the OS-home
|
|
15
|
+
# level (Worktree.home_from_store, scripts/lib/worktree.rb:90); this script normalizes
|
|
16
|
+
# both spellings to that so either caller convention works.
|
|
17
|
+
#
|
|
18
|
+
# Steps performed:
|
|
19
|
+
# 0. Resolve inputs: the intent dir, normalize --home, resolve the calling session
|
|
20
|
+
# (--session, else CLAUDE_CODE_SESSION_ID, else the existing delivery.lock's own
|
|
21
|
+
# recorded owner_session when non-blank, else nil; unlike start-intent, the
|
|
22
|
+
# lock-owner fallback IS used here, matching end-intent's teardown resolution order).
|
|
23
|
+
# Load the bridge (Bridge.read); a nil bridge means there is nothing to finish (exit
|
|
24
|
+
# 0, loud: the worktree this intent provisioned, if any, was NOT removed). No
|
|
25
|
+
# worktree.code on the bridge means nothing was provisioned (exit 0).
|
|
26
|
+
# 1. The order precondition (delivered only): calls the EXISTING
|
|
27
|
+
# Bridge.code_gate_decision predicate exactly as it exists. On an auto bridge that
|
|
28
|
+
# has not reached How, this BLOCKS (exit 2). Bridge.code_gate_decision returns nil
|
|
29
|
+
# UNCONDITIONALLY on a guided bridge (build.auto != true), so on a guided bridge
|
|
30
|
+
# this precondition is ADVISORY ONLY: it always passes and the run proceeds. This
|
|
31
|
+
# script is NOT the enforcement point either way; the hook layer
|
|
32
|
+
# (scripts/lib/bridge.rb code_gate_decision, wired through scripts/lib/edit_gates.rb)
|
|
33
|
+
# remains the actual gate. See scripts/lib/exec_worktree.rb's module doc for why the
|
|
34
|
+
# auto-only condition is never relaxed and never duplicated here.
|
|
35
|
+
# 2. The dirty-worktree guard (delivered only): git status --porcelain on the code
|
|
36
|
+
# worktree. Not clean, or the check itself fails, fails CLOSED (exit 3), mirroring
|
|
37
|
+
# end-intent's dirty-worktree handling. abandoned skips this guard entirely: the
|
|
38
|
+
# branch survives and can be reclaimed, so a dirty worktree is not a reason to
|
|
39
|
+
# refuse.
|
|
40
|
+
# 3. Finish: Worktree.finish(bridge_data, home:, runner:, merge: disposition ==
|
|
41
|
+
# "delivered"). Worktree.finish is fail-open and never raises, so failure is
|
|
42
|
+
# detected by checking, for a delivered disposition only, whether the worktree
|
|
43
|
+
# directory still exists on disk afterward (release removes it on success).
|
|
44
|
+
#
|
|
45
|
+
# No test suite is run by this script, in any form. verify-intent owns test execution;
|
|
46
|
+
# one concern per script (spec D8).
|
|
47
|
+
#
|
|
48
|
+
# Exit codes:
|
|
49
|
+
# 0 finished (merged or removed per disposition), or nothing to finish (no bridge
|
|
50
|
+
# resolved, or no code worktree recorded on the bridge)
|
|
51
|
+
# 1 usage or path-resolution failure
|
|
52
|
+
# 2 the order precondition blocked a delivered disposition (auto bridge, How not
|
|
53
|
+
# reached)
|
|
54
|
+
# 3 the worktree is not clean, or the merge/removal did not complete (delivered only)
|
|
55
|
+
# 4 lock or session resolution failure
|
|
56
|
+
|
|
57
|
+
require_relative "lib/exec_worktree"
|
|
58
|
+
|
|
59
|
+
def parse_args(argv)
|
|
60
|
+
opts = { store: nil, id: nil, home: nil, disposition: nil, session: nil }
|
|
61
|
+
i = 0
|
|
62
|
+
while i < argv.length
|
|
63
|
+
arg = argv[i]
|
|
64
|
+
case arg
|
|
65
|
+
when "--store" then opts[:store] = argv[i += 1]
|
|
66
|
+
when "--id" then opts[:id] = argv[i += 1]
|
|
67
|
+
when "--home" then opts[:home] = argv[i += 1]
|
|
68
|
+
when "--disposition" then opts[:disposition] = argv[i += 1]
|
|
69
|
+
when "--session" then opts[:session] = argv[i += 1]
|
|
70
|
+
else
|
|
71
|
+
usage_abort("unknown argument #{arg.inspect}")
|
|
72
|
+
end
|
|
73
|
+
i += 1
|
|
74
|
+
end
|
|
75
|
+
opts
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def usage
|
|
79
|
+
"usage: exec-worktree --store <store_path> --id <intent_id> --home <plastic_home> " \
|
|
80
|
+
"--disposition delivered|abandoned [--session <session_id>]"
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def usage_abort(message)
|
|
84
|
+
warn "exec-worktree: #{message}"
|
|
85
|
+
warn usage
|
|
86
|
+
exit 1
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def main(argv)
|
|
90
|
+
opts = parse_args(argv)
|
|
91
|
+
|
|
92
|
+
result = ExecWorktree.run(
|
|
93
|
+
store: opts[:store], id: opts[:id], home: opts[:home], disposition: opts[:disposition],
|
|
94
|
+
session: opts[:session], env_session: ENV["CLAUDE_CODE_SESSION_ID"]
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
Array(result[:stderr]).each { |line| warn line }
|
|
98
|
+
warn usage if result[:exit_code] == ExecWorktree::EXIT_USAGE
|
|
99
|
+
Array(result[:stdout]).each { |line| puts line }
|
|
100
|
+
exit result[:exit_code]
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
main(ARGV) if $PROGRAM_NAME == __FILE__
|
package/scripts/hash-intent
CHANGED
|
@@ -13,7 +13,7 @@ fi
|
|
|
13
13
|
|
|
14
14
|
# Try Ruby first (macOS/Linux default)
|
|
15
15
|
if command -v ruby &>/dev/null; then
|
|
16
|
-
ruby -r digest -e 'puts Digest::SHA256.hexdigest(ARGV[0]).to_i(16).to_s(36)[0,6]' "$INTENT_NAME"
|
|
16
|
+
env -u RUBYOPT ruby -r digest -e 'puts Digest::SHA256.hexdigest(ARGV[0]).to_i(16).to_s(36)[0,6]' "$INTENT_NAME"
|
|
17
17
|
exit 0
|
|
18
18
|
fi
|
|
19
19
|
|
package/scripts/hook-bash-gate
CHANGED
|
@@ -55,5 +55,24 @@ bridge_data = Bridge.discover_bridge(session: session, cwd: cwd)
|
|
|
55
55
|
reason = Bridge.bash_gate_decision(bridge_data, command, cwd: cwd, session: session)
|
|
56
56
|
exit 0 unless reason
|
|
57
57
|
|
|
58
|
+
# Block log (intent 229): one six-field TSV line, hand-rolled here exactly as
|
|
59
|
+
# the escape write above is, so this script keeps requiring only lib/bridge and
|
|
60
|
+
# pays no extra load on every Bash tool call. Best-effort: the block applies
|
|
61
|
+
# regardless.
|
|
62
|
+
begin
|
|
63
|
+
require "fileutils"
|
|
64
|
+
require "time"
|
|
65
|
+
blocklog = File.join(Dir.home, ".plastic", ".cache", "gate-blocks.log")
|
|
66
|
+
FileUtils.mkdir_p(File.dirname(blocklog))
|
|
67
|
+
intent_id = bridge_data.is_a?(Hash) ? bridge_data.dig("intent", "id").to_s : ""
|
|
68
|
+
File.open(blocklog, "a") do |io|
|
|
69
|
+
io.puts([Time.now.utc.iso8601, "bash-gate", session.to_s, intent_id,
|
|
70
|
+
command.to_s.gsub(/\s+/, " ").strip,
|
|
71
|
+
reason.to_s.gsub(/\s+/, " ").strip].join("\t"))
|
|
72
|
+
end
|
|
73
|
+
rescue StandardError
|
|
74
|
+
# the block still applies; logging is best-effort
|
|
75
|
+
end
|
|
76
|
+
|
|
58
77
|
$stderr.puts "PLASTIC GATE — #{reason}"
|
|
59
78
|
exit 2
|
package/scripts/hook-code-gate
CHANGED
|
@@ -15,7 +15,10 @@
|
|
|
15
15
|
#
|
|
16
16
|
# Thin CLI wrapper (intent 244): the gate logic itself lives in
|
|
17
17
|
# scripts/lib/edit_gates.rb, shared with the merged Claude dispatcher
|
|
18
|
-
# (scripts/hook-edit-gates)
|
|
18
|
+
# (scripts/hook-edit-gates) and, since intent 251, the merged Codex dispatcher
|
|
19
|
+
# (scripts/lib/codex_edit_gates.rb). This wrapper has no production caller on
|
|
20
|
+
# either harness anymore; it remains as the per-gate isolation surface for the
|
|
21
|
+
# hook test suite.
|
|
19
22
|
|
|
20
23
|
require_relative "lib/edit_gates"
|
|
21
24
|
|
package/scripts/hook-continue
CHANGED
|
@@ -15,7 +15,7 @@ exit 0 unless index_path && File.exist?(index_path)
|
|
|
15
15
|
dashboard = File.expand_path("dashboard.rb", __dir__)
|
|
16
16
|
exit 0 unless File.exist?(dashboard)
|
|
17
17
|
|
|
18
|
-
cockpit, _err, status = Open3.capture3("ruby", dashboard, "continue")
|
|
18
|
+
cockpit, _err, status = Open3.capture3({"RUBYOPT" => nil}, "ruby", dashboard, "continue")
|
|
19
19
|
exit 0 unless status.success? && !cockpit.strip.empty?
|
|
20
20
|
|
|
21
21
|
context = cockpit.rstrip +
|
|
@@ -36,7 +36,7 @@ payload = {
|
|
|
36
36
|
# degrades silently, omitting systemMessage; this hook must never crash
|
|
37
37
|
# UserPromptSubmit over a summary line.
|
|
38
38
|
begin
|
|
39
|
-
data_json, _data_err, data_status = Open3.capture3("ruby", dashboard, "continue", "--data")
|
|
39
|
+
data_json, _data_err, data_status = Open3.capture3({"RUBYOPT" => nil}, "ruby", dashboard, "continue", "--data")
|
|
40
40
|
if data_status.success?
|
|
41
41
|
banner = DashboardBanner.render(JSON.parse(data_json))
|
|
42
42
|
payload["systemMessage"] = banner if banner
|
package/scripts/hook-create-gate
CHANGED
|
@@ -29,9 +29,12 @@
|
|
|
29
29
|
#
|
|
30
30
|
# Thin CLI wrapper (intent 244): the gate logic itself lives in
|
|
31
31
|
# scripts/lib/edit_gates.rb, shared with the merged Claude dispatcher
|
|
32
|
-
# (scripts/hook-edit-gates)
|
|
33
|
-
#
|
|
34
|
-
#
|
|
32
|
+
# (scripts/hook-edit-gates) and, since intent 251, the merged Codex dispatcher
|
|
33
|
+
# (scripts/lib/codex_edit_gates.rb). This wrapper has no production caller on
|
|
34
|
+
# either harness anymore; it remains as the per-gate isolation surface for the
|
|
35
|
+
# hook test suite. Deliberately WITHOUT a top-level rescue, matching this
|
|
36
|
+
# script's behavior before the extraction: an internal exception here has
|
|
37
|
+
# never been caught.
|
|
35
38
|
|
|
36
39
|
require "json"
|
|
37
40
|
require_relative "lib/edit_gates"
|
package/scripts/hook-gate-check
CHANGED
|
@@ -99,6 +99,23 @@ if is_stage_file || is_action_file
|
|
|
99
99
|
error = Bridge.check_gate(bridge_intent_dir_abs, file_path_abs)
|
|
100
100
|
|
|
101
101
|
if error
|
|
102
|
+
# Block log (intent 229): same hand-rolled best-effort write as
|
|
103
|
+
# hook-bash-gate's; this script keeps its current requires.
|
|
104
|
+
begin
|
|
105
|
+
require "fileutils"
|
|
106
|
+
require "time"
|
|
107
|
+
blocklog = File.join(Dir.home, ".plastic", ".cache", "gate-blocks.log")
|
|
108
|
+
FileUtils.mkdir_p(File.dirname(blocklog))
|
|
109
|
+
File.open(blocklog, "a") do |io|
|
|
110
|
+
io.puts([Time.now.utc.iso8601, "gate-check", session.to_s,
|
|
111
|
+
Bridge.intent_id_from_dir(bridge_intent_dir_abs).to_s,
|
|
112
|
+
file_path_abs.to_s.gsub(/\s+/, " ").strip,
|
|
113
|
+
error.to_s.gsub(/\s+/, " ").strip].join("\t"))
|
|
114
|
+
end
|
|
115
|
+
rescue StandardError
|
|
116
|
+
# the block still applies; logging is best-effort
|
|
117
|
+
end
|
|
118
|
+
|
|
102
119
|
# Gate violation — block the write
|
|
103
120
|
bridge_data["build"]["gate_failures"] = (bridge_data["build"]["gate_failures"] || 0) + 1
|
|
104
121
|
Bridge.write(session, bridge_data)
|
package/scripts/hook-links-gate
CHANGED
|
@@ -16,7 +16,10 @@
|
|
|
16
16
|
#
|
|
17
17
|
# Thin CLI wrapper (intent 244): the gate logic itself lives in
|
|
18
18
|
# scripts/lib/edit_gates.rb, shared with the merged Claude dispatcher
|
|
19
|
-
# (scripts/hook-edit-gates)
|
|
19
|
+
# (scripts/hook-edit-gates) and, since intent 251, the merged Codex dispatcher
|
|
20
|
+
# (scripts/lib/codex_edit_gates.rb). This wrapper has no production caller on
|
|
21
|
+
# either harness anymore; it remains as the per-gate isolation surface for the
|
|
22
|
+
# hook test suite.
|
|
20
23
|
|
|
21
24
|
require "json"
|
|
22
25
|
require_relative "lib/edit_gates"
|
package/scripts/hook-lock-gate
CHANGED
|
@@ -11,7 +11,10 @@
|
|
|
11
11
|
#
|
|
12
12
|
# Thin CLI wrapper (intent 244): the gate logic itself lives in
|
|
13
13
|
# scripts/lib/edit_gates.rb, shared with the merged Claude dispatcher
|
|
14
|
-
# (scripts/hook-edit-gates)
|
|
14
|
+
# (scripts/hook-edit-gates) and, since intent 251, the merged Codex dispatcher
|
|
15
|
+
# (scripts/lib/codex_edit_gates.rb). This wrapper has no production caller on
|
|
16
|
+
# either harness anymore; it remains as the per-gate isolation surface for the
|
|
17
|
+
# hook test suite.
|
|
15
18
|
|
|
16
19
|
require_relative "lib/edit_gates"
|
|
17
20
|
|
|
@@ -21,8 +24,9 @@ begin
|
|
|
21
24
|
session = (ARGV[1] unless ARGV[1].to_s.empty?) || ENV["CLAUDE_CODE_SESSION_ID"]
|
|
22
25
|
# Usage: hook-lock-gate <file_path> [session_id] [harness]. Claude's own bash
|
|
23
26
|
# shim never passes a third arg, so harness falls through to Lock's default
|
|
24
|
-
# (claude, slash form)
|
|
25
|
-
# "codex"
|
|
27
|
+
# (claude, slash form). Since intent 251 the merged Codex library
|
|
28
|
+
# (scripts/lib/codex_edit_gates.rb) sets harness: "codex" directly on the
|
|
29
|
+
# Context it builds; no caller of this wrapper passes "codex" anymore.
|
|
26
30
|
harness = (ARGV[2] unless ARGV[2].to_s.empty?) || "claude"
|
|
27
31
|
|
|
28
32
|
ctx = EditGates::Context.new(
|
|
@@ -16,7 +16,10 @@
|
|
|
16
16
|
#
|
|
17
17
|
# Thin CLI wrapper (intent 244): the gate logic itself lives in
|
|
18
18
|
# scripts/lib/edit_gates.rb, shared with the merged Claude dispatcher
|
|
19
|
-
# (scripts/hook-edit-gates)
|
|
19
|
+
# (scripts/hook-edit-gates) and, since intent 251, the merged Codex dispatcher
|
|
20
|
+
# (scripts/lib/codex_edit_gates.rb). This wrapper has no production caller on
|
|
21
|
+
# either harness anymore; it remains as the per-gate isolation surface for the
|
|
22
|
+
# hook test suite.
|
|
20
23
|
|
|
21
24
|
require_relative "lib/edit_gates"
|
|
22
25
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env ruby
|
|
2
2
|
# encoding: UTF-8
|
|
3
|
-
# Usage: hook-session-start <index_path> <
|
|
3
|
+
# Usage: hook-session-start <index_path> <plastic_home> <mode> [plugin_root]
|
|
4
4
|
# Outputs hook JSON for the session-start hook. Exits silently if nothing to show.
|
|
5
5
|
|
|
6
6
|
require "json"
|
|
@@ -9,10 +9,16 @@ require "yaml"
|
|
|
9
9
|
require_relative "lib/bridge"
|
|
10
10
|
require_relative "lib/boot_banner"
|
|
11
11
|
require_relative "lib/qmd_sync"
|
|
12
|
-
require_relative "
|
|
12
|
+
require_relative "lib/doctor_core"
|
|
13
13
|
|
|
14
|
-
index_path,
|
|
15
|
-
exit 0 unless index_path &&
|
|
14
|
+
index_path, plastic_home, mode, plugin_root = ARGV
|
|
15
|
+
exit 0 unless index_path && plastic_home && mode
|
|
16
|
+
|
|
17
|
+
# Plastic home and the store are two different paths (intent 231). The shim passes
|
|
18
|
+
# home (~/.plastic) as argument 2; the store lives one level below it. Compose the
|
|
19
|
+
# store exactly once here, so no later line re-derives it and no path can gain a
|
|
20
|
+
# doubled store/ segment.
|
|
21
|
+
store_dir = File.join(plastic_home, "store")
|
|
16
22
|
|
|
17
23
|
# --- Parse INDEX.md ---
|
|
18
24
|
|
|
@@ -47,11 +53,11 @@ end
|
|
|
47
53
|
bridge_data = nil
|
|
48
54
|
if active.length == 1 && active.first =~ /store\/([\w-]+)\//
|
|
49
55
|
dir_name = $1
|
|
50
|
-
intent_dir = "#{
|
|
56
|
+
intent_dir = "#{store_dir}/#{dir_name}"
|
|
51
57
|
session = ENV["CLAUDE_CODE_SESSION_ID"] || Process.pid.to_s
|
|
52
58
|
intent_id = dir_name.split("--").first
|
|
53
59
|
intent_name = active.first[/\[([^\]]+)\]/, 1] || "unknown"
|
|
54
|
-
bridge_data = Bridge.derive(session, intent_id: intent_id, intent_dir: intent_dir, store:
|
|
60
|
+
bridge_data = Bridge.derive(session, intent_id: intent_id, intent_dir: intent_dir, store: store_dir, name: intent_name)
|
|
55
61
|
end
|
|
56
62
|
|
|
57
63
|
# --- Detect stale future intents ---
|
|
@@ -68,7 +74,7 @@ stale_days = 3 if stale_days == 0
|
|
|
68
74
|
future.each do |f|
|
|
69
75
|
if f =~ /store\/([\w-]+)\//
|
|
70
76
|
dir_name = $1
|
|
71
|
-
intent_file = "#{
|
|
77
|
+
intent_file = "#{store_dir}/#{dir_name}/#{dir_name}.md"
|
|
72
78
|
next unless File.exist?(intent_file)
|
|
73
79
|
|
|
74
80
|
content = File.read(intent_file)
|
|
@@ -88,7 +94,7 @@ current_project = nil
|
|
|
88
94
|
project_active = []
|
|
89
95
|
project_future = []
|
|
90
96
|
|
|
91
|
-
projects_path = "#{
|
|
97
|
+
projects_path = "#{plastic_home}/projects.yml"
|
|
92
98
|
if File.exist?(projects_path)
|
|
93
99
|
projects = YAML.safe_load(File.read(projects_path)) rescue {}
|
|
94
100
|
cwd = Dir.pwd
|
|
@@ -96,7 +102,7 @@ if File.exist?(projects_path)
|
|
|
96
102
|
project_path = File.expand_path(info["path"])
|
|
97
103
|
if cwd.start_with?(project_path)
|
|
98
104
|
current_project = { "slug" => slug, "parent" => info["parent"], "path" => project_path }
|
|
99
|
-
project_index = "#{
|
|
105
|
+
project_index = "#{plastic_home}/projects/#{slug}/INDEX.md"
|
|
100
106
|
if File.exist?(project_index)
|
|
101
107
|
p_section = nil
|
|
102
108
|
File.readlines(project_index).each do |pline|
|
|
@@ -122,7 +128,7 @@ end
|
|
|
122
128
|
|
|
123
129
|
# --- Load PLASTIC.md conventions ---
|
|
124
130
|
|
|
125
|
-
plastic_md_path = "#{
|
|
131
|
+
plastic_md_path = "#{plastic_home}/PLASTIC.md"
|
|
126
132
|
plastic_md = File.exist?(plastic_md_path) ? File.read(plastic_md_path).strip : nil
|
|
127
133
|
|
|
128
134
|
# --- Load deprecations ---
|
|
@@ -130,7 +136,7 @@ plastic_md = File.exist?(plastic_md_path) ? File.read(plastic_md_path).strip : n
|
|
|
130
136
|
dep_file = if plugin_root && !plugin_root.empty?
|
|
131
137
|
"#{plugin_root}/deprecations.yml"
|
|
132
138
|
else
|
|
133
|
-
"#{
|
|
139
|
+
"#{plastic_home}/deprecations.yml"
|
|
134
140
|
end
|
|
135
141
|
|
|
136
142
|
deprecations = []
|
|
@@ -147,7 +153,7 @@ rescue
|
|
|
147
153
|
end
|
|
148
154
|
|
|
149
155
|
current_version = nil
|
|
150
|
-
version_file = "#{
|
|
156
|
+
version_file = "#{plastic_home}/VERSION"
|
|
151
157
|
if File.exist?(version_file)
|
|
152
158
|
current_version = File.read(version_file).strip
|
|
153
159
|
elsif plugin_root && !plugin_root.empty?
|
|
@@ -167,7 +173,7 @@ end
|
|
|
167
173
|
# --- Check for available updates (from previous session's check) ---
|
|
168
174
|
|
|
169
175
|
update_notice = nil
|
|
170
|
-
cache_file = "#{
|
|
176
|
+
cache_file = "#{plastic_home}/.cache/update-check.json"
|
|
171
177
|
if File.exist?(cache_file)
|
|
172
178
|
cache = JSON.parse(File.read(cache_file)) rescue {}
|
|
173
179
|
if cache["updateAvailable"]
|
|
@@ -181,7 +187,7 @@ end
|
|
|
181
187
|
# session: any failure or exception degrades to a banner and we continue.
|
|
182
188
|
|
|
183
189
|
core_health = begin
|
|
184
|
-
Doctor.new(plastic_home:
|
|
190
|
+
Doctor.new(plastic_home: plastic_home).run_core_checks("claude")
|
|
185
191
|
rescue
|
|
186
192
|
nil
|
|
187
193
|
end
|
|
@@ -212,7 +218,7 @@ parts << ""
|
|
|
212
218
|
# slow/broken/missing qmd appends nothing and the hook continues cleanly.
|
|
213
219
|
begin
|
|
214
220
|
require "timeout"
|
|
215
|
-
qmd_status = Timeout.timeout(2) { QmdSync.status(plastic_home:
|
|
221
|
+
qmd_status = Timeout.timeout(2) { QmdSync.status(plastic_home: plastic_home) }
|
|
216
222
|
if qmd_status[:present]
|
|
217
223
|
if qmd_status[:all_registered]
|
|
218
224
|
parts << "QMD: #{qmd_status[:registered].size} Plastic collections indexed (search with the qmd skill)."
|
|
@@ -13,16 +13,27 @@
|
|
|
13
13
|
# Part 4] the hook stdin schema, including `tool_input.command` as the carrier of
|
|
14
14
|
# the apply_patch envelope text (consumed by scripts/codex-hook).
|
|
15
15
|
#
|
|
16
|
-
#
|
|
17
|
-
#
|
|
18
|
-
#
|
|
19
|
-
#
|
|
20
|
-
# this parser
|
|
21
|
-
#
|
|
22
|
-
#
|
|
23
|
-
#
|
|
24
|
-
#
|
|
25
|
-
#
|
|
16
|
+
# Grammar provenance (intent 239, 2026-08-12). The V4A envelope grammar parsed below
|
|
17
|
+
# is primary-sourced: it is embedded verbatim in the native codex binary and was
|
|
18
|
+
# extracted from codex-cli 0.146.0 into test/fixtures/codex-v4a-grammar.txt, which
|
|
19
|
+
# carries the binary's path, size and SHA256 in its header.
|
|
20
|
+
# test/codex_v4a_grammar_test.rb checks this parser's markers against that grammar,
|
|
21
|
+
# and a live test re-extracts from an installed binary so the fixture cannot drift
|
|
22
|
+
# (it skips cleanly on a machine with no codex).
|
|
23
|
+
#
|
|
24
|
+
# Two deliberate differences from the real grammar, both in the safe direction:
|
|
25
|
+
# codex requires the envelope's first line to be "*** Begin Patch" and its last to be
|
|
26
|
+
# "*** End Patch", while this parser scans for the markers on any line of the
|
|
27
|
+
# payload, not only the first and last; and "*** Environment ID:", a real production
|
|
28
|
+
# of the grammar, is ignored here rather than parsed, because it names no file
|
|
29
|
+
# operation. Both markers still must form a WHOLE LINE (start-of-text or a
|
|
30
|
+
# preceding LF, and end-of-text or a following LF): per add_line: "+" /(.*)/ LF, a
|
|
31
|
+
# content line such as "+*** End Patch" is legal file content, not a terminator, so a
|
|
32
|
+
# bare substring search would misparse it as the real marker (see
|
|
33
|
+
# line_anchored_index below). Anything else unparseable FAILS OPEN: parse returns []
|
|
34
|
+
# and warns, so gates allow the write (orchestrator-locks fail-open rule), and the
|
|
35
|
+
# PostToolUse gate-check backstop re-validates the intent file after the write
|
|
36
|
+
# lands.
|
|
26
37
|
module ApplyPatchEnvelope
|
|
27
38
|
module_function
|
|
28
39
|
|
|
@@ -39,9 +50,11 @@ module ApplyPatchEnvelope
|
|
|
39
50
|
# patch text by scanning for the Begin/End markers regardless of wrapping.
|
|
40
51
|
def parse(command)
|
|
41
52
|
text = command.is_a?(Array) ? command.join("\n") : command.to_s
|
|
42
|
-
b = text
|
|
43
|
-
|
|
44
|
-
|
|
53
|
+
b = line_anchored_index(text, BEGIN_MARK, 0)
|
|
54
|
+
return warn_empty("no Begin/End Patch markers") if b.nil?
|
|
55
|
+
|
|
56
|
+
e = line_anchored_index(text, END_MARK, b + BEGIN_MARK.length)
|
|
57
|
+
return warn_empty("no Begin/End Patch markers") if e.nil?
|
|
45
58
|
|
|
46
59
|
body = text[(b + BEGIN_MARK.length)...e]
|
|
47
60
|
ops = []
|
|
@@ -73,4 +86,24 @@ module ApplyPatchEnvelope
|
|
|
73
86
|
$stderr.puts "plastic apply_patch parse: #{reason}; gate fails open"
|
|
74
87
|
[]
|
|
75
88
|
end
|
|
89
|
+
|
|
90
|
+
# Finds the first occurrence of `mark` starting at or after `from` that forms a
|
|
91
|
+
# WHOLE LINE: immediately preceded by start-of-text or a newline, and immediately
|
|
92
|
+
# followed by end-of-text or a newline. A bare `text.index(mark)` would also match
|
|
93
|
+
# `mark` sitting inside a longer line, such as the content line "+*** End Patch"
|
|
94
|
+
# (legal per add_line: "+" /(.*)/ LF), which is not a terminator.
|
|
95
|
+
def line_anchored_index(text, mark, from)
|
|
96
|
+
pos = from
|
|
97
|
+
loop do
|
|
98
|
+
idx = text.index(mark, pos)
|
|
99
|
+
return nil if idx.nil?
|
|
100
|
+
|
|
101
|
+
line_start = idx.zero? || text[idx - 1] == "\n"
|
|
102
|
+
after = idx + mark.length
|
|
103
|
+
line_end = after == text.length || text[after] == "\n"
|
|
104
|
+
return idx if line_start && line_end
|
|
105
|
+
|
|
106
|
+
pos = idx + 1
|
|
107
|
+
end
|
|
108
|
+
end
|
|
76
109
|
end
|
package/scripts/lib/bridge.rb
CHANGED
|
@@ -9,6 +9,7 @@ require "digest"
|
|
|
9
9
|
require "socket"
|
|
10
10
|
require_relative "worktree"
|
|
11
11
|
require_relative "lock"
|
|
12
|
+
require_relative "spec_header"
|
|
12
13
|
|
|
13
14
|
module Bridge
|
|
14
15
|
STAGES = %w[what why how exec done].freeze
|
|
@@ -662,13 +663,10 @@ module Bridge
|
|
|
662
663
|
# matching the skill and agent contracts). Returns nil when spec.md is
|
|
663
664
|
# absent, empty, or its first line does not match, so a missing/malformed
|
|
664
665
|
# Tier line changes nothing about existing rebuild behavior.
|
|
666
|
+
# The grammar itself now lives in SpecHeader (scripts/lib/spec_header.rb, intent 213);
|
|
667
|
+
# this method is a thin read on top of it.
|
|
665
668
|
def self.savepoint_tier(intent_dir)
|
|
666
|
-
|
|
667
|
-
return nil unless File.exist?(path)
|
|
668
|
-
first = File.open(path, &:gets)
|
|
669
|
-
return nil if first.nil?
|
|
670
|
-
m = first.chomp.strip.match(/\ATier:\s*(S|M|L)\z/)
|
|
671
|
-
m && m[1]
|
|
669
|
+
SpecHeader.parse_file(File.join(intent_dir, "spec.md"))[:tier]
|
|
672
670
|
end
|
|
673
671
|
|
|
674
672
|
# Reconstruct the ledger from files on disk (timestamps from mtimes), in
|
|
@@ -767,12 +765,16 @@ module Bridge
|
|
|
767
765
|
phantoms
|
|
768
766
|
end
|
|
769
767
|
|
|
770
|
-
|
|
768
|
+
# Pure compute (intent 230): build the bridge state and write NOTHING. `derive`
|
|
769
|
+
# is the writing wrapper over this; `arm` and `repair_lock` use the pure form so
|
|
770
|
+
# the single bridge write happens only after the delivery lock and the worktree
|
|
771
|
+
# have both settled. Joins the pure `derive_stage` / `derive_key` family.
|
|
772
|
+
def self.derive_data(session, intent_id:, intent_dir:, store:, name:)
|
|
771
773
|
stage = derive_stage(intent_dir)
|
|
772
774
|
has = has_files(intent_dir)
|
|
773
775
|
missing = missing_for_stage(stage, intent_dir) - has
|
|
774
776
|
|
|
775
|
-
|
|
777
|
+
{
|
|
776
778
|
"session" => session,
|
|
777
779
|
"intent" => {
|
|
778
780
|
"id" => intent_id,
|
|
@@ -816,7 +818,14 @@ module Bridge
|
|
|
816
818
|
"delegates" => []
|
|
817
819
|
}
|
|
818
820
|
}
|
|
821
|
+
end
|
|
819
822
|
|
|
823
|
+
# Compute AND persist. Contract unchanged (intent 230 kept it deliberately):
|
|
824
|
+
# `scripts/hook-session-start` calls this and wants the immediate write, and
|
|
825
|
+
# test/bridge_worktree_derive_test.rb pins write-on-call.
|
|
826
|
+
def self.derive(session, intent_id:, intent_dir:, store:, name:, tmp: tmp_dir)
|
|
827
|
+
data = derive_data(session, intent_id: intent_id, intent_dir: intent_dir,
|
|
828
|
+
store: store, name: name)
|
|
820
829
|
write(session, data, tmp: tmp)
|
|
821
830
|
data
|
|
822
831
|
end
|
|
@@ -881,6 +890,19 @@ module Bridge
|
|
|
881
890
|
|
|
882
891
|
# --- Auto mode (intent 27) ---
|
|
883
892
|
|
|
893
|
+
# Intent 230: freshly composed state carries worktree.code = nil, so a failed
|
|
894
|
+
# Worktree.provision would have nothing to keep. Seed the block from the bridge
|
|
895
|
+
# already on disk when it names a code path, so provision's keep-rule (see
|
|
896
|
+
# Worktree.provision) can preserve it. Provision SUCCESS overwrites this with
|
|
897
|
+
# the freshly resolved (identical) pointer, so this only matters on failure.
|
|
898
|
+
def self.carry_prior_worktree(data, session, tmp: tmp_dir)
|
|
899
|
+
prior = read(session, intent_id: data.dig("intent", "id"), tmp: tmp)
|
|
900
|
+
block = prior && prior["worktree"]
|
|
901
|
+
data["worktree"] = block if block.is_a?(Hash) && !blank?(block["code"])
|
|
902
|
+
data
|
|
903
|
+
end
|
|
904
|
+
private_class_method :carry_prior_worktree
|
|
905
|
+
|
|
884
906
|
# Shared arming spine (intent 96): resolve the session key, derive intent state,
|
|
885
907
|
# set the caller-controlled auto flag, acquire the delivery lock, provision the
|
|
886
908
|
# per-intent worktrees, persist, and purge terminal bridges. arm_auto (auto: true)
|
|
@@ -893,7 +915,10 @@ module Bridge
|
|
|
893
915
|
if blank?(session) && blank?(ENV["CLAUDE_CODE_SESSION_ID"])
|
|
894
916
|
$stderr.puts "plastic: no session id available; arming with derived bridge key #{key}"
|
|
895
917
|
end
|
|
896
|
-
|
|
918
|
+
# Compute only (intent 230). Nothing reaches disk until the lock is ours and
|
|
919
|
+
# the worktree has settled; a LockHeldError below must leave the previous
|
|
920
|
+
# bridge exactly as it was.
|
|
921
|
+
data = derive_data(key, intent_id: intent_id, intent_dir: intent_dir, store: store, name: name)
|
|
897
922
|
data["build"]["auto"] = auto
|
|
898
923
|
|
|
899
924
|
# Acquire the durable delivery lock (D1/D2): session-keyed, O_EXCL, in the
|
|
@@ -924,6 +949,8 @@ module Bridge
|
|
|
924
949
|
"run #{skill_ref('plastic-doctor', harness: harness)} fix the lock"
|
|
925
950
|
end
|
|
926
951
|
|
|
952
|
+
carry_prior_worktree(data, key)
|
|
953
|
+
|
|
927
954
|
# Provision the per-intent worktrees (mandatory code worktree for project
|
|
928
955
|
# intents; fail-open for non-git / global-only). Never let a provision error
|
|
929
956
|
# break arming: the lock and auto flag still matter.
|
|
@@ -959,13 +986,24 @@ module Bridge
|
|
|
959
986
|
# Degrade path for disarm_auto when no intent_id is given (intent 131): the
|
|
960
987
|
# session's sole per-intent bridge when there is exactly one, else the
|
|
961
988
|
# legacy single-key file. Keeps the common single-intent auto path working
|
|
962
|
-
# without every caller having to name the intent id explicitly.
|
|
989
|
+
# without every caller having to name the intent id explicitly. With TWO or
|
|
990
|
+
# more per-intent bridges it refuses to guess (intent 233); see below.
|
|
963
991
|
def self.sole_bridge_data(session, tmp: tmp_dir)
|
|
964
992
|
matches = Dir.glob(File.join(tmp, "plastic-#{session}--*.json")).reject { |f| f.end_with?(".tmp") }
|
|
965
993
|
if matches.length == 1
|
|
966
994
|
data = (JSON.parse(File.read(matches.first)) rescue nil)
|
|
967
995
|
return data if data
|
|
968
996
|
end
|
|
997
|
+
# Refuse to guess among siblings (intent 233): with 2+ per-intent bridges
|
|
998
|
+
# the legacy single-key file below can carry EITHER sibling, so falling
|
|
999
|
+
# through would let a no-id disarm release the wrong intent's lock. A
|
|
1000
|
+
# disarm that does nothing is recoverable; one that unlocks a live
|
|
1001
|
+
# delivery is not. Zero matches keeps the legacy fallback (131 migration).
|
|
1002
|
+
if matches.length > 1
|
|
1003
|
+
$stderr.puts "plastic: session #{session} has #{matches.length} bridges; " \
|
|
1004
|
+
"disarm needs an explicit intent_id (refusing to guess)"
|
|
1005
|
+
return nil
|
|
1006
|
+
end
|
|
969
1007
|
read(session, tmp: tmp)
|
|
970
1008
|
end
|
|
971
1009
|
|
|
@@ -979,6 +1017,9 @@ module Bridge
|
|
|
979
1017
|
# (one per concurrent intent), so disarm must target ONE of them. When
|
|
980
1018
|
# intent_id is nil, degrades to the session's sole bridge (see
|
|
981
1019
|
# sole_bridge_data) so the common single-intent path keeps working.
|
|
1020
|
+
#
|
|
1021
|
+
# The lock clear is conditional (intent 233): a failed release leaves the
|
|
1022
|
+
# cached lock fields alone so the orphan stays findable.
|
|
982
1023
|
def self.disarm_auto(session, intent_id: nil)
|
|
983
1024
|
data = blank?(intent_id) ? sole_bridge_data(session) : read(session, intent_id: intent_id)
|
|
984
1025
|
return nil unless data
|
|
@@ -994,14 +1035,39 @@ module Bridge
|
|
|
994
1035
|
$stderr.puts "plastic: worktree release raised, continuing: #{e.message}"
|
|
995
1036
|
end
|
|
996
1037
|
|
|
1038
|
+
# Check what the release actually DID before touching the cache (intent
|
|
1039
|
+
# 233). Blanking the cache after a failed release orphans the durable
|
|
1040
|
+
# delivery.lock: the file stays on disk and nothing points at it any more.
|
|
1041
|
+
# Rescue mirrors the Worktree.release rescue above: warn, never raise,
|
|
1042
|
+
# never abort the rest of the tail (the guard fails milder than the bug).
|
|
997
1043
|
dir = bridge_intent_dir(data)
|
|
1044
|
+
release_status = nil
|
|
998
1045
|
if dir
|
|
999
1046
|
owner = data.dig("lock", "owner_session")
|
|
1000
1047
|
owner = session if blank?(owner)
|
|
1001
|
-
|
|
1048
|
+
begin
|
|
1049
|
+
release_status = Lock.release(dir, session: owner)
|
|
1050
|
+
rescue => e
|
|
1051
|
+
release_status = :raised
|
|
1052
|
+
$stderr.puts "plastic: delivery lock release raised for #{dir}, continuing: #{e.message}"
|
|
1053
|
+
end
|
|
1002
1054
|
end
|
|
1003
|
-
|
|
1004
|
-
|
|
1055
|
+
|
|
1056
|
+
# Success means "no lock left on disk": :released (we deleted it), :none
|
|
1057
|
+
# (there was none), and nil (no intent dir, so no release was attempted).
|
|
1058
|
+
# Only :not_owner and a raised release keep the cache pointing at the lock
|
|
1059
|
+
# so plastic-lock fix / reclaim / doctor can still find and repair it.
|
|
1060
|
+
if release_status.nil? || release_status == :released || release_status == :none
|
|
1061
|
+
data["lock"] = { "owner_session" => nil, "acquired_at" => nil,
|
|
1062
|
+
"host" => nil, "type" => nil, "delegates" => [] }
|
|
1063
|
+
else
|
|
1064
|
+
$stderr.puts "plastic: delivery lock NOT released for #{dir} (#{release_status}); " \
|
|
1065
|
+
"bridge lock cache preserved for repair"
|
|
1066
|
+
data["lock"] = {} unless data["lock"].is_a?(Hash)
|
|
1067
|
+
end
|
|
1068
|
+
# Diagnostic only (D5): the primary contract stays "owner_session non-nil
|
|
1069
|
+
# after disarm means the lock was not released".
|
|
1070
|
+
data["lock"]["release_status"] = release_status.nil? ? nil : release_status.to_s
|
|
1005
1071
|
|
|
1006
1072
|
write(session, data)
|
|
1007
1073
|
purge_done_bridges(session: session)
|
|
@@ -1070,11 +1136,13 @@ module Bridge
|
|
|
1070
1136
|
actions << "lock #{status}"
|
|
1071
1137
|
end
|
|
1072
1138
|
|
|
1073
|
-
data =
|
|
1074
|
-
|
|
1139
|
+
data = derive_data(key, intent_id: intent_id, intent_dir: dir, store: store,
|
|
1140
|
+
name: name)
|
|
1075
1141
|
data["build"]["auto"] = auto
|
|
1076
1142
|
data["lock"] = lock_cache(lock_data)
|
|
1077
1143
|
|
|
1144
|
+
carry_prior_worktree(data, key, tmp: tmp)
|
|
1145
|
+
|
|
1078
1146
|
# Provision the per-intent worktrees so the rebuilt bridge carries
|
|
1079
1147
|
# worktree.code (intent 136). Without it, cwd/edited-path selection has no
|
|
1080
1148
|
# key: the repaired intent loses its own code gate and a concurrent sibling
|