@zalom/plastic 1.0.0-beta.9 → 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.
Files changed (107) hide show
  1. package/PLASTIC-reference.md +133 -0
  2. package/PLASTIC.md +398 -82
  3. package/README.md +99 -82
  4. package/agents/plastic-brainstorming.md +12 -9
  5. package/agents/plastic-enforcer.md +52 -12
  6. package/agents/plastic-executor.md +10 -10
  7. package/agents/plastic-future-intent-researcher.md +4 -8
  8. package/agents/plastic-intent-curator.md +12 -14
  9. package/agents/plastic-intent-discovery.md +35 -0
  10. package/agents/plastic-planner.md +15 -10
  11. package/agents/plastic-spec-specialist.md +15 -10
  12. package/bin/plastic.js +7 -3
  13. package/hooks/code-gate +10 -1
  14. package/hooks/hooks.json +28 -3
  15. package/hooks/lock-gate +21 -0
  16. package/hooks/retrieval-gate +10 -0
  17. package/hooks/savepoint-pre +10 -0
  18. package/hooks/statusline +48 -6
  19. package/package.json +2 -1
  20. package/scripts/agent-report +21 -0
  21. package/scripts/dashboard.rb +98 -23
  22. package/scripts/doctor.rb +232 -5
  23. package/scripts/hook-auto-arm +1 -1
  24. package/scripts/hook-bash-gate +22 -4
  25. package/scripts/hook-code-gate +11 -6
  26. package/scripts/hook-continue +17 -0
  27. package/scripts/hook-create-gate +45 -9
  28. package/scripts/hook-gate-check +24 -23
  29. package/scripts/hook-lock-gate +83 -0
  30. package/scripts/hook-retrieval-gate +148 -0
  31. package/scripts/hook-savepoint-pre +32 -0
  32. package/scripts/hook-session-start +1 -1
  33. package/scripts/insight-append +51 -0
  34. package/scripts/install.rb +44 -7
  35. package/scripts/lib/agent_models.rb +43 -0
  36. package/scripts/lib/bridge.rb +924 -84
  37. package/scripts/lib/dashboard_banner.rb +42 -0
  38. package/scripts/lib/hook_registry.rb +95 -0
  39. package/scripts/lib/insights.rb +86 -0
  40. package/scripts/lib/installer_core.rb +100 -60
  41. package/scripts/lib/link_suggestions.rb +319 -0
  42. package/scripts/lib/lock.rb +375 -0
  43. package/scripts/lib/power_tools.rb +22 -21
  44. package/scripts/lib/preflight.rb +79 -0
  45. package/scripts/lib/qmd_sync.rb +15 -0
  46. package/scripts/lib/retrieval_gate.rb +211 -0
  47. package/scripts/lib/worktree.rb +384 -0
  48. package/scripts/link-suggest +213 -0
  49. package/scripts/new-intent +9 -1
  50. package/scripts/plastic-lock +164 -0
  51. package/scripts/read-config +4 -0
  52. package/scripts/spawn-preamble +11 -3
  53. package/scripts/update.rb +16 -7
  54. package/skills/auto/SKILL.md +126 -19
  55. package/skills/auto/references/agent-architecture.md +7 -4
  56. package/skills/auto/references/agent-report-contract.md +36 -1
  57. package/skills/auto/references/end-tail.md +56 -0
  58. package/skills/auto/references/human-report-contract.md +55 -0
  59. package/skills/auto/references/tiers.md +77 -0
  60. package/skills/brainstorming/SKILL.md +7 -34
  61. package/skills/brainstorming/references/design-principles.md +49 -0
  62. package/skills/continuing/SKILL.md +26 -7
  63. package/skills/creating-intent/SKILL.md +13 -28
  64. package/skills/creating-project/SKILL.md +11 -74
  65. package/skills/creating-project/references/project-scaffolding.md +97 -0
  66. package/skills/creating-skills/SKILL.md +65 -0
  67. package/skills/creating-skills/evals/evals.json +108 -0
  68. package/skills/creating-skills/references/agents.md +168 -0
  69. package/skills/creating-skills/references/evals.md +41 -0
  70. package/skills/creating-skills/references/hooks.md +248 -0
  71. package/skills/creating-skills/references/progressive-disclosure.md +176 -0
  72. package/skills/creating-skills/references/scripts.md +166 -0
  73. package/skills/creating-skills/references/skills.md +165 -0
  74. package/skills/creating-skills/scripts/scaffold.rb +313 -0
  75. package/skills/dashboard/SKILL.md +13 -11
  76. package/skills/dashboard/references/classification.md +22 -0
  77. package/skills/doctor/SKILL.md +10 -7
  78. package/skills/executing-plan/SKILL.md +4 -4
  79. package/skills/humanizer/SKILL.md +39 -0
  80. package/skills/humanizer/evals/evals.json +70 -0
  81. package/skills/humanizer/references/always-on-snippet.md +9 -0
  82. package/skills/humanizer/references/examples.md +48 -0
  83. package/skills/install/SKILL.md +75 -84
  84. package/skills/intent-curator/SKILL.md +5 -1
  85. package/skills/intent-discovery/SKILL.md +47 -0
  86. package/skills/intent-starting/SKILL.md +130 -0
  87. package/skills/intent-starting/evals/evals.json +117 -0
  88. package/skills/intent-starting/references/boarding-matrix.md +35 -0
  89. package/skills/linking-intents/SKILL.md +48 -12
  90. package/skills/lock/SKILL.md +41 -0
  91. package/skills/managing-index/SKILL.md +6 -0
  92. package/skills/releasing/SKILL.md +44 -29
  93. package/skills/releasing/references/promotion-and-tagging.md +60 -0
  94. package/skills/roadmap/SKILL.md +51 -0
  95. package/skills/roadmap/references/file-format.md +91 -0
  96. package/skills/roadmap/references/operations.md +78 -0
  97. package/skills/uninstall/SKILL.md +29 -11
  98. package/skills/update/SKILL.md +34 -23
  99. package/skills/versions/SKILL.md +27 -12
  100. package/skills/writing-plans/SKILL.md +10 -88
  101. package/skills/writing-plans/references/plan-format.md +102 -0
  102. package/templates/config.yml +8 -0
  103. package/templates/outcome.md +3 -0
  104. package/templates/revisions.md +58 -0
  105. package/templates/roadmap.md +30 -0
  106. package/skills/writing-instructions/SKILL.md +0 -159
  107. package/skills/writing-instructions/references/agentskills-spec.md +0 -135
@@ -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["CLAUDE_SESSION_ID"] || Process.pid.to_s
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}"
@@ -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.\n\n"
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 Re-sync core files for the installed version (repair). Store untouched.
123
- --force Overwrite existing files without prompting
124
- -h, --help Show this help
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