@zalom/plastic 1.0.0-beta.8 → 1.0.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-reference.md +133 -0
- package/PLASTIC.md +407 -84
- package/README.md +99 -82
- package/agents/plastic-brainstorming.md +12 -9
- package/agents/plastic-enforcer.md +52 -12
- package/agents/plastic-executor.md +10 -10
- package/agents/plastic-future-intent-researcher.md +4 -8
- package/agents/plastic-intent-curator.md +12 -14
- package/agents/plastic-intent-discovery.md +35 -0
- package/agents/plastic-planner.md +15 -10
- package/agents/plastic-spec-specialist.md +15 -10
- package/bin/plastic.js +7 -3
- package/hooks/code-gate +10 -1
- package/hooks/hooks.json +28 -3
- package/hooks/lock-gate +21 -0
- package/hooks/retrieval-gate +10 -0
- package/hooks/savepoint-pre +10 -0
- package/hooks/statusline +48 -6
- package/package.json +2 -1
- package/scripts/agent-report +21 -0
- package/scripts/dashboard.rb +98 -23
- package/scripts/doctor.rb +404 -5
- package/scripts/hook-auto-arm +1 -1
- package/scripts/hook-bash-gate +22 -4
- package/scripts/hook-code-gate +11 -6
- package/scripts/hook-continue +17 -0
- package/scripts/hook-create-gate +45 -9
- package/scripts/hook-gate-check +24 -23
- package/scripts/hook-lock-gate +83 -0
- package/scripts/hook-retrieval-gate +148 -0
- package/scripts/hook-savepoint-pre +32 -0
- package/scripts/hook-session-start +1 -1
- package/scripts/insight-append +51 -0
- package/scripts/install.rb +44 -7
- package/scripts/lib/agent_models.rb +43 -0
- package/scripts/lib/bridge.rb +924 -84
- package/scripts/lib/dashboard_banner.rb +42 -0
- package/scripts/lib/frontmatter_writer.rb +130 -0
- package/scripts/lib/graph_rebuild.rb +328 -0
- package/scripts/lib/hook_registry.rb +95 -0
- package/scripts/lib/insights.rb +86 -0
- package/scripts/lib/installer_core.rb +104 -60
- package/scripts/lib/link_suggestions.rb +319 -0
- package/scripts/lib/links_projection.rb +160 -0
- package/scripts/lib/links_section.rb +207 -0
- package/scripts/lib/lock.rb +375 -0
- package/scripts/lib/power_tools.rb +22 -21
- package/scripts/lib/preflight.rb +79 -0
- package/scripts/lib/qmd_sync.rb +15 -0
- package/scripts/lib/retrieval_gate.rb +211 -0
- package/scripts/lib/worktree.rb +384 -0
- package/scripts/link-suggest +213 -0
- package/scripts/new-intent +138 -29
- package/scripts/plastic-lock +164 -0
- package/scripts/project-links +287 -0
- package/scripts/read-config +4 -0
- package/scripts/rebuild-graph +244 -0
- package/scripts/spawn-preamble +11 -3
- package/scripts/update.rb +16 -7
- package/skills/auto/SKILL.md +126 -19
- package/skills/auto/references/agent-architecture.md +7 -4
- package/skills/auto/references/agent-report-contract.md +36 -1
- package/skills/auto/references/end-tail.md +56 -0
- package/skills/auto/references/human-report-contract.md +55 -0
- package/skills/auto/references/tiers.md +77 -0
- package/skills/brainstorming/SKILL.md +7 -34
- package/skills/brainstorming/references/design-principles.md +49 -0
- package/skills/continuing/SKILL.md +26 -7
- package/skills/creating-intent/SKILL.md +13 -28
- package/skills/creating-intent/references/lifecycle.md +9 -4
- package/skills/creating-project/SKILL.md +11 -74
- package/skills/creating-project/references/project-scaffolding.md +97 -0
- package/skills/creating-skills/SKILL.md +65 -0
- package/skills/creating-skills/evals/evals.json +108 -0
- package/skills/creating-skills/references/agents.md +168 -0
- package/skills/creating-skills/references/evals.md +41 -0
- package/skills/creating-skills/references/hooks.md +248 -0
- package/skills/creating-skills/references/progressive-disclosure.md +176 -0
- package/skills/creating-skills/references/scripts.md +166 -0
- package/skills/creating-skills/references/skills.md +165 -0
- package/skills/creating-skills/scripts/scaffold.rb +313 -0
- package/skills/dashboard/SKILL.md +13 -11
- package/skills/dashboard/references/classification.md +22 -0
- package/skills/doctor/SKILL.md +10 -7
- package/skills/executing-plan/SKILL.md +4 -4
- package/skills/humanizer/SKILL.md +39 -0
- package/skills/humanizer/evals/evals.json +70 -0
- package/skills/humanizer/references/always-on-snippet.md +9 -0
- package/skills/humanizer/references/examples.md +48 -0
- package/skills/install/SKILL.md +75 -84
- package/skills/intent-curator/SKILL.md +5 -1
- package/skills/intent-discovery/SKILL.md +47 -0
- package/skills/intent-starting/SKILL.md +130 -0
- package/skills/intent-starting/evals/evals.json +117 -0
- package/skills/intent-starting/references/boarding-matrix.md +35 -0
- package/skills/linking-intents/SKILL.md +48 -12
- package/skills/linking-intents/references/zettelkasten.md +7 -0
- package/skills/lock/SKILL.md +41 -0
- package/skills/managing-index/SKILL.md +6 -0
- package/skills/managing-index/references/zettelkasten-linking.md +6 -1
- package/skills/releasing/SKILL.md +44 -29
- package/skills/releasing/references/promotion-and-tagging.md +60 -0
- package/skills/roadmap/SKILL.md +51 -0
- package/skills/roadmap/references/file-format.md +91 -0
- package/skills/roadmap/references/operations.md +78 -0
- package/skills/uninstall/SKILL.md +29 -11
- package/skills/update/SKILL.md +34 -23
- package/skills/versions/SKILL.md +27 -12
- package/skills/writing-plans/SKILL.md +10 -88
- package/skills/writing-plans/references/plan-format.md +102 -0
- package/templates/config.yml +8 -0
- package/templates/outcome.md +3 -0
- package/templates/revisions.md +58 -0
- package/templates/roadmap.md +30 -0
- package/skills/writing-instructions/SKILL.md +0 -159
- package/skills/writing-instructions/references/agentskills-spec.md +0 -135
package/scripts/hook-gate-check
CHANGED
|
@@ -9,11 +9,12 @@
|
|
|
9
9
|
require "json"
|
|
10
10
|
require_relative "lib/bridge"
|
|
11
11
|
require_relative "lib/intent_validator"
|
|
12
|
+
require_relative "lib/lock"
|
|
12
13
|
|
|
13
14
|
file_path = ARGV[0]
|
|
14
15
|
exit 0 unless file_path && !file_path.empty?
|
|
15
16
|
|
|
16
|
-
session = (ARGV[1] unless ARGV[1].to_s.empty?) || ENV["
|
|
17
|
+
session = (ARGV[1] unless ARGV[1].to_s.empty?) || ENV["CLAUDE_CODE_SESSION_ID"]
|
|
17
18
|
|
|
18
19
|
file_path_abs = File.expand_path(file_path)
|
|
19
20
|
|
|
@@ -24,6 +25,12 @@ intent_dir_abs = Bridge.intent_dir_for(file_path_abs)
|
|
|
24
25
|
if intent_dir_abs
|
|
25
26
|
begin
|
|
26
27
|
Bridge.append_savepoint(intent_dir_abs, file_path_abs)
|
|
28
|
+
# When checklist.md lands, How ends and Exec begins: emit the `Exec started`
|
|
29
|
+
# companion in the same event (intent 81). Guard on a real (non-placeholder)
|
|
30
|
+
# checklist so a scaffold sentinel does not trip it.
|
|
31
|
+
if File.basename(file_path_abs) == "checklist.md" && Bridge.stage_file_present?(file_path_abs)
|
|
32
|
+
Bridge.append_exec_started(intent_dir_abs)
|
|
33
|
+
end
|
|
27
34
|
rescue StandardError
|
|
28
35
|
# ignore — rebuildable from disk
|
|
29
36
|
end
|
|
@@ -66,6 +73,15 @@ intent_dir = "#{store}/#{dir}"
|
|
|
66
73
|
# Normalize both paths for comparison
|
|
67
74
|
bridge_intent_dir_abs = File.expand_path(intent_dir)
|
|
68
75
|
|
|
76
|
+
# Lease heartbeat (intent 108, D1): every write by the session that owns (or
|
|
77
|
+
# delegates under) the lock refreshes the delivery.lock mtime. Best-effort:
|
|
78
|
+
# a heartbeat failure must never break the gate hook.
|
|
79
|
+
begin
|
|
80
|
+
Lock.heartbeat(bridge_intent_dir_abs, session: session)
|
|
81
|
+
rescue StandardError
|
|
82
|
+
# ignore
|
|
83
|
+
end
|
|
84
|
+
|
|
69
85
|
# Not inside intent dir = not our business
|
|
70
86
|
exit 0 unless file_path_abs.start_with?("#{bridge_intent_dir_abs}/")
|
|
71
87
|
|
|
@@ -112,32 +128,17 @@ if is_stage_file || is_action_file
|
|
|
112
128
|
|
|
113
129
|
Bridge.write(session, bridge_data)
|
|
114
130
|
|
|
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
|
|
131
|
+
# Build transition context — ONE concise sentence (intent 84, Lever 1),
|
|
132
|
+
# preserving the `Next: ...` hint. Formatting is pure in Bridge.gate_narration.
|
|
133
|
+
context = Bridge.gate_narration(
|
|
134
|
+
old_stage: old_stage, new_stage: new_stage,
|
|
135
|
+
basename: basename, new_missing: new_missing
|
|
136
|
+
)
|
|
136
137
|
|
|
137
138
|
payload = {
|
|
138
139
|
"hookSpecificOutput" => {
|
|
139
140
|
"hookEventName" => "PostToolUse",
|
|
140
|
-
"additionalContext" =>
|
|
141
|
+
"additionalContext" => context
|
|
141
142
|
}
|
|
142
143
|
}
|
|
143
144
|
puts JSON.generate(payload)
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# encoding: UTF-8
|
|
3
|
+
# frozen_string_literal: true
|
|
4
|
+
#
|
|
5
|
+
# Usage: hook-lock-gate <file_path> [session_id]
|
|
6
|
+
# Fail-CLOSED PreToolUse gate (intent 96). Blocks a mutating write to an active
|
|
7
|
+
# intent's lifecycle dir when THIS session holds no live lock. Project code is NOT
|
|
8
|
+
# gated here (D2). Emits the PreToolUse JSON deny contract at exit 0; reserves
|
|
9
|
+
# non-zero exit for hook-internal errors only. Matcher Write|Edit|NotebookEdit, so
|
|
10
|
+
# reads never reach here.
|
|
11
|
+
require "json"
|
|
12
|
+
require_relative "lib/bridge"
|
|
13
|
+
require_relative "lib/lock"
|
|
14
|
+
|
|
15
|
+
begin
|
|
16
|
+
file_path = ARGV[0]
|
|
17
|
+
exit 0 unless file_path && !file_path.empty?
|
|
18
|
+
session = (ARGV[1] unless ARGV[1].to_s.empty?) || ENV["CLAUDE_CODE_SESSION_ID"]
|
|
19
|
+
|
|
20
|
+
bridge_data = Bridge.discover_bridge(session: session, cwd: Dir.pwd) # nil = no bridge cache
|
|
21
|
+
reason = Bridge.lock_gate_decision(bridge_data, file_path, session: session)
|
|
22
|
+
unless reason
|
|
23
|
+
# Allow path: refresh the lease for the session that holds this target's
|
|
24
|
+
# lock (owner or delegate). Best-effort, never blocks.
|
|
25
|
+
begin
|
|
26
|
+
dir = Bridge.intent_dir_for(file_path)
|
|
27
|
+
Lock.heartbeat(dir, session: session) if dir && !session.to_s.empty?
|
|
28
|
+
rescue StandardError
|
|
29
|
+
# ignore
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Second, independent gate (intent 111 D7): the artifact-grain claim.
|
|
33
|
+
# Composes UNDER the delivery lock above; only reached once the delivery
|
|
34
|
+
# lock already allows. Dormant (nil) unless a claim file exists for this
|
|
35
|
+
# artifact, so AC7 (existing single-owner flows) is unaffected.
|
|
36
|
+
begin
|
|
37
|
+
dir = Bridge.intent_dir_for(file_path)
|
|
38
|
+
artifact = File.basename(file_path) if dir
|
|
39
|
+
if dir && artifact
|
|
40
|
+
claim_reason = Claim.claim_gate_reason(dir, artifact, session: session)
|
|
41
|
+
if claim_reason
|
|
42
|
+
print JSON.generate(
|
|
43
|
+
"hookSpecificOutput" => {
|
|
44
|
+
"hookEventName" => "PreToolUse",
|
|
45
|
+
"permissionDecision" => "deny",
|
|
46
|
+
"permissionDecisionReason" => claim_reason,
|
|
47
|
+
}
|
|
48
|
+
)
|
|
49
|
+
exit 0
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Fail-open surface (AC3): allow, but make the yielded/stale claim visible.
|
|
53
|
+
if Claim.fail_open?(dir, artifact)
|
|
54
|
+
$stderr.puts "plastic claim gate: unresolvable claim on #{artifact} in " \
|
|
55
|
+
"intent #{Bridge.intent_id_from_dir(dir)} yielded; write " \
|
|
56
|
+
"proceeds (see /plastic-lock status)"
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Heartbeat the session's own claim on the allow path (best-effort).
|
|
60
|
+
Claim.heartbeat(dir, artifact, session: session) rescue nil
|
|
61
|
+
end
|
|
62
|
+
rescue StandardError
|
|
63
|
+
# ignore: a claim-gate bug must never block a write (fail open)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
exit 0
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
print JSON.generate(
|
|
70
|
+
"hookSpecificOutput" => {
|
|
71
|
+
"hookEventName" => "PreToolUse",
|
|
72
|
+
"permissionDecision" => "deny",
|
|
73
|
+
"permissionDecisionReason" => reason # "run /plastic-intent-starting to lock and begin"
|
|
74
|
+
}
|
|
75
|
+
)
|
|
76
|
+
exit 0
|
|
77
|
+
rescue => e
|
|
78
|
+
# Hook-internal failure only: do NOT impersonate a user-deny. Log and fail open
|
|
79
|
+
# here so a gate bug never bricks editing; the fail-CLOSED intent is about MISSING
|
|
80
|
+
# locks, not crashes.
|
|
81
|
+
$stderr.puts "plastic lock-gate error: #{e.message}"
|
|
82
|
+
exit 0
|
|
83
|
+
end
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# encoding: UTF-8
|
|
3
|
+
# frozen_string_literal: true
|
|
4
|
+
|
|
5
|
+
# PreToolUse retrieval gate (intent 84, Lever 2; operation-based redesign 89a;
|
|
6
|
+
# ADVISORY since intent 108, D8). Reads the tool call (JSON on stdin: tool_name +
|
|
7
|
+
# tool_input), computes capabilities (QMD detect + freshness), and delegates the
|
|
8
|
+
# decision to RetrievalGate. It NEVER blocks: reads and searches always run
|
|
9
|
+
# (exit 0). A store content search that QMD could serve better gets a QMD hint
|
|
10
|
+
# via PreToolUse additionalContext on stdout. Hard gates guard writes, locks,
|
|
11
|
+
# and structure; never reads.
|
|
12
|
+
# Fail-open: any parse error, timeout, or unexpected exception exits 0. On the
|
|
13
|
+
# STALE QMD path RetrievalGate fires QmdSync.reindex_async (NEVER synchronous).
|
|
14
|
+
# Binds subagents (PreToolUse hooks apply to subagent tool calls too).
|
|
15
|
+
#
|
|
16
|
+
# Only CONTENT SEARCH over store markdown gets the hint; reads and structural
|
|
17
|
+
# ops stay silent. Code navigation is a soft prompt recommendation
|
|
18
|
+
# (UserPromptSubmit power-tools), not handled here.
|
|
19
|
+
#
|
|
20
|
+
# Scope: only the agent's own Bash/Read/Grep/Glob calls. Ruby `File.read` inside
|
|
21
|
+
# scripts is invisible to a PreToolUse hook and is out of scope (no exemptions).
|
|
22
|
+
#
|
|
23
|
+
# ARGV[0] is plastic_home (passed by the launcher), like hook-qmd-search.
|
|
24
|
+
|
|
25
|
+
require "json"
|
|
26
|
+
require "timeout"
|
|
27
|
+
require_relative "lib/retrieval_gate"
|
|
28
|
+
require_relative "lib/qmd_sync"
|
|
29
|
+
|
|
30
|
+
module RetrievalGateHook
|
|
31
|
+
module_function
|
|
32
|
+
|
|
33
|
+
# Pure-ish core: capabilities and reindex are injected so this is unit-testable
|
|
34
|
+
# with no real qmd. Returns [exit_code, stderr_string, stdout_json].
|
|
35
|
+
# stdin: raw PreToolUse JSON
|
|
36
|
+
# capabilities: { qmd:, qmd_fresh: }
|
|
37
|
+
# reindex: callable fired on the STALE path
|
|
38
|
+
def run(stdin:, plastic_home:, cwd:, capabilities:, reindex: -> {})
|
|
39
|
+
payload = parse(stdin)
|
|
40
|
+
return [0, nil, nil] unless payload
|
|
41
|
+
|
|
42
|
+
tool_name = payload["tool_name"].to_s
|
|
43
|
+
tool_input = payload["tool_input"]
|
|
44
|
+
tool_input = {} unless tool_input.is_a?(Hash)
|
|
45
|
+
|
|
46
|
+
bypassed = false
|
|
47
|
+
reason = RetrievalGate.decision(
|
|
48
|
+
tool_name: tool_name, tool_input: tool_input,
|
|
49
|
+
plastic_home: plastic_home, cwd: cwd,
|
|
50
|
+
capabilities: capabilities, reindex: reindex
|
|
51
|
+
) { |_sig| bypassed = true }
|
|
52
|
+
|
|
53
|
+
if reason
|
|
54
|
+
# Advisory (intent 108, D8): reads and searches are never denied. The
|
|
55
|
+
# would-block reason becomes a hint the agent sees alongside the result.
|
|
56
|
+
hint = JSON.generate(
|
|
57
|
+
"hookSpecificOutput" => {
|
|
58
|
+
"hookEventName" => "PreToolUse",
|
|
59
|
+
"additionalContext" =>
|
|
60
|
+
"PLASTIC advisory: #{reason} (this search ran; the hint is not a block)"
|
|
61
|
+
}
|
|
62
|
+
)
|
|
63
|
+
[0, nil, hint]
|
|
64
|
+
else
|
|
65
|
+
# The `# qmd-ok` bypass token is still accepted (bypassed is set), but
|
|
66
|
+
# with nothing to bypass it no longer announces itself.
|
|
67
|
+
[0, nil, nil]
|
|
68
|
+
end
|
|
69
|
+
rescue StandardError
|
|
70
|
+
[0, nil, nil] # fail-open
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def parse(raw)
|
|
74
|
+
data = JSON.parse(raw.to_s)
|
|
75
|
+
data.is_a?(Hash) ? data : nil
|
|
76
|
+
rescue StandardError
|
|
77
|
+
nil
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# Detect real capabilities for the live executable. Probes are injected so the
|
|
81
|
+
# three-tier QMD failure model is unit-testable:
|
|
82
|
+
# - absent : QMD not on PATH -> allow, no warn.
|
|
83
|
+
# - broken : QMD present but freshness probe -> tier-b: WARN once, then treat
|
|
84
|
+
# raises/times out as absent this turn (allow).
|
|
85
|
+
# - fresh : probe returns truthy -> gate is live.
|
|
86
|
+
# A slow `qmd status` cannot stall a tool call: a 2s Timeout bounds the probe.
|
|
87
|
+
def detect_capabilities(cwd:,
|
|
88
|
+
detect: -> { QmdSync.detect },
|
|
89
|
+
fresh: -> { QmdSync.fresh? },
|
|
90
|
+
warn: ->(m) { $stderr.puts(m) })
|
|
91
|
+
qmd = detect.call
|
|
92
|
+
qmd_fresh = false
|
|
93
|
+
if qmd
|
|
94
|
+
begin
|
|
95
|
+
qmd_fresh = Timeout.timeout(2) { fresh.call }
|
|
96
|
+
rescue StandardError
|
|
97
|
+
# Tier-b: QMD is present but its freshness probe broke/stalled. Distinct
|
|
98
|
+
# from QMD being absent — warn so a degraded QMD is visible, then fail open
|
|
99
|
+
# (allow this turn, no reindex).
|
|
100
|
+
warn.call("PLASTIC GATE — QMD is present but its freshness probe failed; " \
|
|
101
|
+
"allowing this turn without routing search to QMD (check qmd).")
|
|
102
|
+
qmd = false
|
|
103
|
+
qmd_fresh = false
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
{ qmd: qmd, qmd_fresh: qmd_fresh }
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# Best-effort reindex callable for the STALE path. Resolves the collection from
|
|
110
|
+
# cwd (project + global) and fires the async reindexer for each; never raises.
|
|
111
|
+
def reindex_for(cwd:, plastic_home:)
|
|
112
|
+
lambda do
|
|
113
|
+
begin
|
|
114
|
+
cols = QmdSync.collections_for_cwd(cwd, plastic_home: plastic_home)
|
|
115
|
+
cols.each { |c| QmdSync.reindex_async(collection: c) }
|
|
116
|
+
rescue StandardError
|
|
117
|
+
# non-fatal; the read is already allowed this turn
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# --- executable entrypoint ---
|
|
124
|
+
if $PROGRAM_NAME == __FILE__
|
|
125
|
+
raw = begin
|
|
126
|
+
$stdin.read
|
|
127
|
+
rescue StandardError
|
|
128
|
+
""
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
plastic_home = (ARGV[0] && !ARGV[0].empty?) ? ARGV[0] : File.expand_path("~/.plastic")
|
|
132
|
+
cwd = Dir.pwd
|
|
133
|
+
|
|
134
|
+
code, err, out = begin
|
|
135
|
+
caps = RetrievalGateHook.detect_capabilities(cwd: cwd)
|
|
136
|
+
RetrievalGateHook.run(
|
|
137
|
+
stdin: raw, plastic_home: plastic_home, cwd: cwd,
|
|
138
|
+
capabilities: caps,
|
|
139
|
+
reindex: RetrievalGateHook.reindex_for(cwd: cwd, plastic_home: plastic_home)
|
|
140
|
+
)
|
|
141
|
+
rescue StandardError
|
|
142
|
+
[0, nil, nil] # fail-open at the outermost boundary too
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
$stderr.puts(err) if err && !err.empty?
|
|
146
|
+
print out if out && !out.empty?
|
|
147
|
+
exit code
|
|
148
|
+
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
|
|
@@ -48,7 +48,7 @@ bridge_data = nil
|
|
|
48
48
|
if active.length == 1 && active.first =~ /store\/([\w-]+)\//
|
|
49
49
|
dir_name = $1
|
|
50
50
|
intent_dir = "#{store_root}/store/#{dir_name}"
|
|
51
|
-
session = ENV["
|
|
51
|
+
session = ENV["CLAUDE_CODE_SESSION_ID"] || Process.pid.to_s
|
|
52
52
|
intent_id = dir_name.split("--").first
|
|
53
53
|
intent_name = active.first[/\[([^\]]+)\]/, 1] || "unknown"
|
|
54
54
|
bridge_data = Bridge.derive(session, intent_id: intent_id, intent_dir: intent_dir, store: store_root, name: intent_name)
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# encoding: UTF-8
|
|
3
|
+
# frozen_string_literal: true
|
|
4
|
+
|
|
5
|
+
# insight-append - the blessed write path for an intent's `## Insights` section
|
|
6
|
+
# (intent 82). A thin CLI wrapper over Insights.append_insight: it formats the
|
|
7
|
+
# `{utc-iso8601} · {stage} · {author}` prefix, validates it, and appends one
|
|
8
|
+
# entry at the bottom of the section (creating the section or file if absent).
|
|
9
|
+
#
|
|
10
|
+
# Sibling to scripts/spawn-preamble and scripts/agent-report. The library's
|
|
11
|
+
# `now:` seam is the test seam; the CLI uses the default Time.now, which is fine
|
|
12
|
+
# because determinism is covered at the library level (test/insights_test.rb).
|
|
13
|
+
#
|
|
14
|
+
# Usage:
|
|
15
|
+
# insight-append <intent_dir> <text> --stage S --author A
|
|
16
|
+
#
|
|
17
|
+
# Exit codes: 0 (entry appended), 2 (usage).
|
|
18
|
+
|
|
19
|
+
require_relative "lib/insights"
|
|
20
|
+
|
|
21
|
+
def parse_args(argv)
|
|
22
|
+
stage = nil
|
|
23
|
+
author = nil
|
|
24
|
+
positional = []
|
|
25
|
+
i = 0
|
|
26
|
+
while i < argv.length
|
|
27
|
+
case argv[i]
|
|
28
|
+
when "--stage"
|
|
29
|
+
stage = argv[i + 1]
|
|
30
|
+
i += 2
|
|
31
|
+
when "--author"
|
|
32
|
+
author = argv[i + 1]
|
|
33
|
+
i += 2
|
|
34
|
+
else
|
|
35
|
+
positional << argv[i]
|
|
36
|
+
i += 1
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
[positional[0], positional[1], stage, author]
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
intent_dir, text, stage, author = parse_args(ARGV)
|
|
43
|
+
|
|
44
|
+
if [intent_dir, text, stage, author].any? { |v| v.nil? || v.to_s.empty? }
|
|
45
|
+
warn "usage: insight-append <intent_dir> <text> --stage S --author A"
|
|
46
|
+
exit 2
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
entry = Insights.append_insight(File.expand_path(intent_dir), text,
|
|
50
|
+
stage: stage, author: author)
|
|
51
|
+
puts "appended: #{entry}"
|
package/scripts/install.rb
CHANGED
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
# so install.rb is the single file-syncer; the ledger action is contextual.
|
|
15
15
|
|
|
16
16
|
require_relative "lib/installer_core"
|
|
17
|
+
require_relative "lib/preflight"
|
|
17
18
|
|
|
18
19
|
class Install < InstallerCore
|
|
19
20
|
def cli(argv = ARGV)
|
|
@@ -22,6 +23,9 @@ class Install < InstallerCore
|
|
|
22
23
|
return 0
|
|
23
24
|
end
|
|
24
25
|
|
|
26
|
+
gate = preflight_gate
|
|
27
|
+
return gate unless gate.zero?
|
|
28
|
+
|
|
25
29
|
force = argv.include?("--force")
|
|
26
30
|
reinstall = argv.include?("--reinstall")
|
|
27
31
|
ledger_action = flag_value(argv, "--ledger-action")
|
|
@@ -42,19 +46,19 @@ class Install < InstallerCore
|
|
|
42
46
|
return 1
|
|
43
47
|
end
|
|
44
48
|
|
|
45
|
-
run(selected: selected, force: force, reinstall: reinstall, ledger_action: ledger_action)
|
|
49
|
+
run(selected: selected, force: force, reinstall: reinstall, ledger_action: ledger_action, argv: argv)
|
|
46
50
|
0
|
|
47
51
|
end
|
|
48
52
|
|
|
49
53
|
# Hermetic entrypoint (no prompting / no exit). Returns the per-agent results array.
|
|
50
|
-
def run(selected:, force: false, reinstall: false, ledger_action: nil)
|
|
54
|
+
def run(selected:, force: false, reinstall: false, ledger_action: nil, argv: ARGV, input: $stdin)
|
|
51
55
|
fresh = !installed?
|
|
52
56
|
mode = fresh ? :install : :update # :update here means "re-sync, skip bootstrap"
|
|
53
57
|
|
|
54
58
|
distribute(mode)
|
|
55
59
|
bootstrap if fresh
|
|
56
60
|
|
|
57
|
-
results = selected.map { |key| install_for_agent(key, force) }
|
|
61
|
+
results = selected.map { |key| install_for_agent(key, force, argv: argv, input: input, reinstall: reinstall) }
|
|
58
62
|
|
|
59
63
|
action = ledger_action || (fresh ? "install" : "reinstall")
|
|
60
64
|
ledger_append(version, action)
|
|
@@ -72,8 +76,37 @@ class Install < InstallerCore
|
|
|
72
76
|
File.exist?(path) ? File.read(path).strip : nil
|
|
73
77
|
end
|
|
74
78
|
|
|
79
|
+
# Injectable pre-flight gate: real probes as default args, printing to an
|
|
80
|
+
# injectable `out:` IO so this is hermetically testable via StringIO. Returns
|
|
81
|
+
# 1 (stop the install) when Ruby is missing/too-old, else 0.
|
|
82
|
+
def preflight_gate(ruby_version: RUBY_VERSION, node_version: node_probe, git_present: git_probe,
|
|
83
|
+
mise_present: mise_probe, out: $stderr)
|
|
84
|
+
result = Preflight.check(ruby_version: ruby_version, node_version: node_version,
|
|
85
|
+
git_present: git_present, mise_present: mise_present)
|
|
86
|
+
result[:messages].each { |message| out.puts(message) }
|
|
87
|
+
result[:fatal] ? 1 : 0
|
|
88
|
+
end
|
|
89
|
+
|
|
75
90
|
private
|
|
76
91
|
|
|
92
|
+
def node_probe
|
|
93
|
+
`node --version`.strip
|
|
94
|
+
rescue StandardError
|
|
95
|
+
""
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def git_probe
|
|
99
|
+
!`command -v git`.strip.empty?
|
|
100
|
+
rescue StandardError
|
|
101
|
+
false
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def mise_probe
|
|
105
|
+
!`command -v mise`.strip.empty?
|
|
106
|
+
rescue StandardError
|
|
107
|
+
false
|
|
108
|
+
end
|
|
109
|
+
|
|
77
110
|
def flag_value(argv, name)
|
|
78
111
|
i = argv.index(name)
|
|
79
112
|
return nil unless i && argv[i + 1]
|
|
@@ -96,7 +129,8 @@ class Install < InstallerCore
|
|
|
96
129
|
verb = mode == :reinstall ? "re-synced" : "installed"
|
|
97
130
|
puts "\n\u{2705} Plastic v#{version} #{verb}."
|
|
98
131
|
puts " Registered for: #{installed.map { |r| r[:agent] }.join(", ")}"
|
|
99
|
-
puts " Run /clear (or restart your agent) to pick up new conventions
|
|
132
|
+
puts " Run /clear (or restart your agent) to pick up new conventions."
|
|
133
|
+
puts " Next: read docs/guides/your-first-intent-in-10-minutes.md\n\n"
|
|
100
134
|
end
|
|
101
135
|
|
|
102
136
|
def show_help
|
|
@@ -119,9 +153,12 @@ class Install < InstallerCore
|
|
|
119
153
|
--alpha Alpha channel
|
|
120
154
|
|
|
121
155
|
Other options:
|
|
122
|
-
--reinstall
|
|
123
|
-
--force
|
|
124
|
-
|
|
156
|
+
--reinstall Re-sync core files for the installed version (repair). Store untouched.
|
|
157
|
+
--force Overwrite existing files without prompting
|
|
158
|
+
--statusline VALUE keep or plastic. If an existing statusline is found, this
|
|
159
|
+
skips the interactive prompt. Interactive sessions ask by
|
|
160
|
+
default; non-interactive sessions default to keep.
|
|
161
|
+
-h, --help Show this help
|
|
125
162
|
|
|
126
163
|
Notes:
|
|
127
164
|
Install is one-shot. If Plastic is already installed, use `update` to upgrade or
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Shared model-tier resolution for Plastic subagents (intent 116).
|
|
4
|
+
#
|
|
5
|
+
# TIER_DEFAULTS mirrors the shipped `agents/*.md` frontmatter so `read-config`
|
|
6
|
+
# can answer `agents.models.<basename>` with the built-in default. The installer
|
|
7
|
+
# does NOT use TIER_DEFAULTS: it applies only genuine config overrides via
|
|
8
|
+
# `override_map`, so shipped frontmatter with no override passes through
|
|
9
|
+
# unchanged.
|
|
10
|
+
module AgentModels
|
|
11
|
+
# Claude Code aliases only (never pinned ids, never Fable). Keys are the agent
|
|
12
|
+
# file basenames without the `.md` extension.
|
|
13
|
+
TIER_DEFAULTS = {
|
|
14
|
+
"plastic-enforcer" => "opus",
|
|
15
|
+
"plastic-brainstorming" => "opus",
|
|
16
|
+
"plastic-planner" => "opus",
|
|
17
|
+
"plastic-spec-specialist" => "sonnet",
|
|
18
|
+
"plastic-executor" => "sonnet",
|
|
19
|
+
"plastic-intent-curator" => "sonnet",
|
|
20
|
+
"plastic-future-intent-researcher" => "sonnet",
|
|
21
|
+
"plastic-intent-discovery" => "sonnet"
|
|
22
|
+
}.freeze
|
|
23
|
+
|
|
24
|
+
module_function
|
|
25
|
+
|
|
26
|
+
# Pull the `agents.models` sub-hash out of a loaded config hash, tolerating a
|
|
27
|
+
# missing or malformed shape. Returns a plain { basename => model } hash.
|
|
28
|
+
def models_section(config)
|
|
29
|
+
return {} unless config.is_a?(Hash)
|
|
30
|
+
agents = config["agents"]
|
|
31
|
+
return {} unless agents.is_a?(Hash)
|
|
32
|
+
section = agents["models"]
|
|
33
|
+
section.is_a?(Hash) ? section : {}
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Override map for the installer: global overrides overlaid by project
|
|
37
|
+
# overrides (project wins). Defaults are intentionally excluded. Unknown agent
|
|
38
|
+
# keys are carried through as-is; install_agents simply never matches them to a
|
|
39
|
+
# copied file, so they are ignored without raising.
|
|
40
|
+
def override_map(project_config: {}, global_config: {})
|
|
41
|
+
models_section(global_config).merge(models_section(project_config))
|
|
42
|
+
end
|
|
43
|
+
end
|