@zalom/plastic 1.0.0-alpha.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. package/LICENSE +21 -0
  2. package/PLASTIC.md +534 -0
  3. package/README.md +88 -0
  4. package/agents/future-intent-researcher.md +38 -0
  5. package/agents/intent-curator.md +40 -0
  6. package/bin/install.js +29 -0
  7. package/deprecations.yml +23 -0
  8. package/hooks/check-update +42 -0
  9. package/hooks/continue +31 -0
  10. package/hooks/future-intent-check +25 -0
  11. package/hooks/gate-check +10 -0
  12. package/hooks/hooks.json +78 -0
  13. package/hooks/run-hook +7 -0
  14. package/hooks/savepoint +6 -0
  15. package/hooks/session-start +9 -0
  16. package/hooks/statusline +16 -0
  17. package/package.json +43 -0
  18. package/scripts/folgezettel-id +40 -0
  19. package/scripts/hash-intent +29 -0
  20. package/scripts/hook-continue +130 -0
  21. package/scripts/hook-future-intent-check +90 -0
  22. package/scripts/hook-gate-check +136 -0
  23. package/scripts/hook-session-start +224 -0
  24. package/scripts/install.rb +474 -0
  25. package/scripts/lib/bridge.rb +139 -0
  26. package/scripts/migrate-folgezettel +535 -0
  27. package/scripts/migrate-to-global +96 -0
  28. package/scripts/read-config +129 -0
  29. package/skills/auto/SKILL.md +127 -0
  30. package/skills/brainstorming-grill-me/SKILL.md +105 -0
  31. package/skills/continuing/SKILL.md +104 -0
  32. package/skills/creating-intent/SKILL.md +122 -0
  33. package/skills/creating-project/SKILL.md +166 -0
  34. package/skills/executing-plan/SKILL.md +120 -0
  35. package/skills/executing-plan/code-quality-reviewer-prompt.md +32 -0
  36. package/skills/executing-plan/implementer-prompt.md +42 -0
  37. package/skills/executing-plan/spec-reviewer-prompt.md +27 -0
  38. package/skills/install/SKILL.md +134 -0
  39. package/skills/intent-curator/SKILL.md +41 -0
  40. package/skills/linking-intents/SKILL.md +72 -0
  41. package/skills/managing-index/SKILL.md +66 -0
  42. package/skills/managing-index/references/zettelkasten-linking.md +27 -0
  43. package/skills/releasing/SKILL.md +124 -0
  44. package/skills/savepoint/SKILL.md +57 -0
  45. package/skills/uninstall/SKILL.md +48 -0
  46. package/skills/update/SKILL.md +69 -0
  47. package/templates/agents.md +46 -0
  48. package/templates/checklist.md +11 -0
  49. package/templates/config.yml +13 -0
  50. package/templates/index.md +13 -0
  51. package/templates/intent.md +24 -0
  52. package/templates/plan.md +11 -0
  53. package/templates/projects.yml +3 -0
  54. package/templates/savepoint.md +13 -0
@@ -0,0 +1,40 @@
1
+ ---
2
+ name: plastic:intent-curator
3
+ description: |
4
+ Use this agent when completing or reviewing intents, reorganizing the index,
5
+ or when the intent store needs maintenance. Examples:
6
+ <example>Context: User has finished implementing a feature.
7
+ user: "This intent is done, clean up the index"
8
+ assistant: "I'll use the intent-curator agent to update the intent status and reorganize INDEX.md"
9
+ <commentary>Intent lifecycle change triggers curator for index maintenance.</commentary></example>
10
+ <example>Context: The intent store has grown and clusters need review.
11
+ user: "Organize the intents"
12
+ assistant: "I'll use the intent-curator to review clusters, flag orphans, and suggest connections"
13
+ <commentary>Periodic maintenance of the Zettelkasten structure.</commentary></example>
14
+ model: inherit
15
+ ---
16
+
17
+ You are the Plastic Intent Curator. Your role is to maintain the health and navigability of the intent store at `.plastic/`.
18
+
19
+ ## Your Responsibilities
20
+
21
+ 1. **Intent lifecycle management** — move intents between Active/Future/Completed in INDEX.md, fill in `## Outcome` sections
22
+ 2. **INDEX.md maintenance** — keep Active/Future/Clusters/Completed sections accurate and well-organized
23
+ 3. **Link discovery** — suggest connections between intents that share topics but aren't linked
24
+ 4. **Cluster management** — create new clusters when 3+ unlinked intents share tags, merge or rename clusters as topics evolve
25
+ 5. **Orphan detection** — flag intents with no links and no cluster membership
26
+
27
+ ## How You Work
28
+
29
+ 1. Scan `~/.plastic/store/*/ID--slug.md` (or project store) to understand the full intent landscape
30
+ 2. Read `~/.plastic/INDEX.md` (or project INDEX.md) to understand current organization
31
+ 3. Compare: are there intents not in any cluster? Missing from Active/Completed? Status mismatches?
32
+ 4. Make targeted edits to INDEX.md and intent frontmatter/links
33
+ 5. Report what you changed
34
+
35
+ ## Constraints
36
+
37
+ - You only edit `~/.plastic/INDEX.md` (or project INDEX.md) and `~/.plastic/store/*/ID--slug.md` (or project store) files
38
+ - You never create new intents — that's the creating-intent skill's job
39
+ - You never modify `## Insights`, `## Context`, or `## Outcome` content sections — those belong to the worker
40
+ - You use Read and grep/find for discovery, Edit for targeted changes
package/bin/install.js ADDED
@@ -0,0 +1,29 @@
1
+ #!/usr/bin/env node
2
+
3
+ // Thin shim — npx entry point that delegates to the Ruby installer.
4
+ // All logic lives in scripts/install.rb. JS is only the distribution mechanism.
5
+
6
+ import { execFileSync } from 'node:child_process'
7
+ import { resolve } from 'node:path'
8
+ import { existsSync } from 'node:fs'
9
+
10
+ const packageRoot = new URL('..', import.meta.url).pathname
11
+ const installer = resolve(packageRoot, 'scripts', 'install.rb')
12
+
13
+ if (!existsSync(installer)) {
14
+ console.error('Error: scripts/install.rb not found in package.')
15
+ process.exit(1)
16
+ }
17
+
18
+ try {
19
+ execFileSync('ruby', [installer, ...process.argv.slice(2)], {
20
+ stdio: 'inherit',
21
+ env: { ...process.env, PLASTIC_PACKAGE_ROOT: packageRoot },
22
+ })
23
+ } catch (err) {
24
+ if (err.status) process.exit(err.status)
25
+ console.error('Error: Ruby is required to install Plastic.')
26
+ console.error(' macOS: Ruby is pre-installed')
27
+ console.error(' Linux: sudo apt install ruby / dnf install ruby')
28
+ process.exit(1)
29
+ }
@@ -0,0 +1,23 @@
1
+ # Plastic deprecation notices
2
+ # Each entry is shown to the user during SessionStart.
3
+ # See PLASTIC.md "Deprecation Process" for the full convention.
4
+ #
5
+ # Schema:
6
+ # id: unique-slug (used for dismissal tracking)
7
+ # severity: info | warning | critical
8
+ # summary: one-line human description
9
+ # migration_steps: ordered list of what user must do
10
+ # introduced: semver where notice was added
11
+ # removal: semver where feature is removed
12
+ # link: optional URL to migration guide
13
+
14
+ deprecations:
15
+ - id: cc-plugin-to-npx
16
+ severity: info
17
+ summary: "Plastic distribution is moving from Claude Code plugin to npx install"
18
+ migration_steps:
19
+ - "Install new distribution: npx @zalom/plastic@latest"
20
+ - "Verify: check that ~/.plastic/ is intact and hooks work"
21
+ - "Remove old plugin: /plugin remove plastic"
22
+ introduced: "1.0.0"
23
+ removal: "2.0.0"
@@ -0,0 +1,42 @@
1
+ #!/bin/bash
2
+ # plastic-hook-version: 1.0.0
3
+ # Runs on SessionStart — checks npm for newer version, writes cache
4
+
5
+ PLASTIC_HOME="$HOME/.plastic"
6
+ CACHE_DIR="$PLASTIC_HOME/.cache"
7
+ CACHE_FILE="$CACHE_DIR/update-check.json"
8
+ VERSION_FILE="$PLASTIC_HOME/VERSION"
9
+
10
+ mkdir -p "$CACHE_DIR"
11
+
12
+ if [ ! -f "$VERSION_FILE" ]; then
13
+ exit 0
14
+ fi
15
+
16
+ CURRENT=$(cat "$VERSION_FILE" | grep -oE '^[0-9]+\.[0-9]+\.[0-9]+(-[a-zA-Z0-9.]+)?$')
17
+
18
+ if [ -z "$CURRENT" ]; then
19
+ exit 0
20
+ fi
21
+
22
+ # Background check — don't block session start
23
+ (
24
+ RAW=$(npm view @zalom/plastic version 2>/dev/null)
25
+ LATEST=$(echo "$RAW" | grep -oE '^[0-9]+\.[0-9]+\.[0-9]+(-[a-zA-Z0-9.]+)?$')
26
+
27
+ if [ -z "$LATEST" ]; then
28
+ exit 0
29
+ fi
30
+
31
+ CHECKED=$(date -u +%Y-%m-%dT%H:%M:%SZ)
32
+
33
+ if [ "$LATEST" != "$CURRENT" ]; then
34
+ printf '{"current":"%s","latest":"%s","checked":"%s","updateAvailable":true}\n' \
35
+ "$CURRENT" "$LATEST" "$CHECKED" > "$CACHE_FILE"
36
+ else
37
+ printf '{"current":"%s","latest":"%s","checked":"%s","updateAvailable":false}\n' \
38
+ "$CURRENT" "$LATEST" "$CHECKED" > "$CACHE_FILE"
39
+ fi
40
+ ) &
41
+
42
+ exit 0
package/hooks/continue ADDED
@@ -0,0 +1,31 @@
1
+ #!/bin/bash
2
+ INPUT=$(cat)
3
+ MESSAGE=$(echo "$INPUT" | ruby -rjson -e 'puts JSON.parse(STDIN.read)["user_prompt"]' 2>/dev/null || echo "")
4
+
5
+ if echo "$MESSAGE" | grep -iq '\bcontinue\b'; then
6
+ GLOBAL_INDEX="$HOME/.plastic/INDEX.md"
7
+
8
+ if [ ! -f "$GLOBAL_INDEX" ]; then
9
+ exit 0
10
+ fi
11
+
12
+ INDEX="$GLOBAL_INDEX"
13
+ STORE_ROOT="$HOME/.plastic"
14
+ MODE="global"
15
+
16
+ SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
17
+ OUTPUT=$(ruby "$SCRIPT_DIR/../scripts/hook-continue" "$INDEX" "$STORE_ROOT" "$MODE" 2>/dev/null)
18
+
19
+ if [ -n "$OUTPUT" ]; then
20
+ echo "$OUTPUT"
21
+ else
22
+ cat <<'HOOKJSON'
23
+ {
24
+ "hookSpecificOutput": {
25
+ "hookEventName": "UserPromptSubmit",
26
+ "additionalContext": "PLASTIC CONTINUE — The user wants to resume previous work.\n\nRead ~/.plastic/INDEX.md and follow the continuing skill workflow."
27
+ }
28
+ }
29
+ HOOKJSON
30
+ fi
31
+ fi
@@ -0,0 +1,25 @@
1
+ #!/bin/bash
2
+ INPUT=$(cat)
3
+ MESSAGE=$(echo "$INPUT" | ruby -rjson -e 'puts JSON.parse(STDIN.read)["user_prompt"].downcase' 2>/dev/null || echo "")
4
+
5
+ if [ ${#MESSAGE} -lt 10 ]; then
6
+ exit 0
7
+ fi
8
+
9
+ if echo "$MESSAGE" | grep -qx '\s*continue\s*'; then
10
+ exit 0
11
+ fi
12
+
13
+ GLOBAL_INDEX="$HOME/.plastic/INDEX.md"
14
+ LOCAL_INDEX=".plastic/INDEX.md"
15
+
16
+ if [ -f "$GLOBAL_INDEX" ]; then
17
+ STORE_ROOT="$HOME/.plastic"
18
+ elif [ -f "$LOCAL_INDEX" ]; then
19
+ STORE_ROOT=".plastic"
20
+ else
21
+ exit 0
22
+ fi
23
+
24
+ SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
25
+ ruby "$SCRIPT_DIR/../scripts/hook-future-intent-check" "$STORE_ROOT" "$MESSAGE"
@@ -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-gate-check" "$FILE_PATH"
@@ -0,0 +1,78 @@
1
+ {
2
+ "hooks": {
3
+ "SessionStart": [
4
+ {
5
+ "matcher": "",
6
+ "hooks": [
7
+ {
8
+ "type": "command",
9
+ "command": "\"${CLAUDE_PLUGIN_ROOT}/hooks/run-hook\" session-start",
10
+ "statusMessage": "Loading Plastic context..."
11
+ },
12
+ {
13
+ "type": "command",
14
+ "command": "\"${CLAUDE_PLUGIN_ROOT}/hooks/check-update\"",
15
+ "statusMessage": ""
16
+ }
17
+ ]
18
+ }
19
+ ],
20
+ "PreCompact": [
21
+ {
22
+ "matcher": "manual|auto",
23
+ "hooks": [
24
+ {
25
+ "type": "command",
26
+ "command": "\"${CLAUDE_PLUGIN_ROOT}/hooks/run-hook\" savepoint",
27
+ "statusMessage": "Saving Plastic intent state..."
28
+ }
29
+ ]
30
+ }
31
+ ],
32
+ "PostToolUse": [
33
+ {
34
+ "matcher": "Write|Edit",
35
+ "hooks": [
36
+ {
37
+ "type": "command",
38
+ "command": "\"${CLAUDE_PLUGIN_ROOT}/hooks/run-hook\" gate-check",
39
+ "statusMessage": "Checking lifecycle gates..."
40
+ }
41
+ ]
42
+ }
43
+ ],
44
+ "UserPromptSubmit": [
45
+ {
46
+ "matcher": "",
47
+ "hooks": [
48
+ {
49
+ "type": "command",
50
+ "command": "\"${CLAUDE_PLUGIN_ROOT}/hooks/run-hook\" continue",
51
+ "statusMessage": "Checking for continue..."
52
+ }
53
+ ]
54
+ },
55
+ {
56
+ "matcher": "",
57
+ "hooks": [
58
+ {
59
+ "type": "command",
60
+ "command": "\"${CLAUDE_PLUGIN_ROOT}/hooks/future-intent-check\"",
61
+ "statusMessage": "Checking future intents..."
62
+ }
63
+ ]
64
+ }
65
+ ],
66
+ "statusLine": [
67
+ {
68
+ "matcher": "",
69
+ "hooks": [
70
+ {
71
+ "type": "command",
72
+ "command": "\"${CLAUDE_PLUGIN_ROOT}/hooks/statusline\""
73
+ }
74
+ ]
75
+ }
76
+ ]
77
+ }
78
+ }
package/hooks/run-hook ADDED
@@ -0,0 +1,7 @@
1
+ #!/bin/bash
2
+ HOOK_DIR="$(cd "$(dirname "$0")" && pwd)"
3
+ HOOK_NAME="$1"
4
+ shift
5
+ if [ -f "$HOOK_DIR/$HOOK_NAME" ]; then
6
+ exec "$HOOK_DIR/$HOOK_NAME" "$@"
7
+ fi
@@ -0,0 +1,6 @@
1
+ #!/bin/bash
2
+ cat <<'HOOKJSON'
3
+ {
4
+ "systemMessage": "PLASTIC SAVEPOINT — Context is being compacted.\n\nYou MUST perform the savepoint procedure NOW:\n1. Read .plastic/INDEX.md to find active intent(s)\n2. For each active intent in .plastic/store/ID--slug/:\n a. Update checklist.md (check off completed items)\n b. Create/update savepoint.md (in-progress work, next steps, blockers, discoveries)\n c. Update intent.md Build/Observe sections with session work\n3. Update .plastic/INDEX.md active section\n4. Commit: git add .plastic/ && git commit -m 'chore: savepoint — [intent name]'\n5. Tell the user: 'Context is getting large. I have saved progress to the active intent. Please run /clear and say continue to resume.'\n\nDO NOT skip this. The user's work depends on it."
5
+ }
6
+ HOOKJSON
@@ -0,0 +1,9 @@
1
+ #!/bin/bash
2
+ GLOBAL_INDEX="$HOME/.plastic/INDEX.md"
3
+
4
+ if [ ! -f "$GLOBAL_INDEX" ]; then
5
+ exit 0
6
+ fi
7
+
8
+ SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
9
+ ruby "$SCRIPT_DIR/../scripts/hook-session-start" "$GLOBAL_INDEX" "$HOME/.plastic" "global" "${CLAUDE_PLUGIN_ROOT:-}"
@@ -0,0 +1,16 @@
1
+ #!/bin/bash
2
+ # plastic-hook-version: 1.0.0
3
+ # StatusLine hook — shows update available warning
4
+
5
+ CACHE_FILE="$HOME/.plastic/.cache/update-check.json"
6
+
7
+ if [ ! -f "$CACHE_FILE" ]; then
8
+ exit 0
9
+ fi
10
+
11
+ UPDATE_AVAILABLE=$(grep -o '"updateAvailable":true' "$CACHE_FILE" 2>/dev/null)
12
+ if [ -n "$UPDATE_AVAILABLE" ]; then
13
+ LATEST=$(grep -o '"latest":"[^"]*"' "$CACHE_FILE" | cut -d'"' -f4)
14
+ CURRENT=$(grep -o '"current":"[^"]*"' "$CACHE_FILE" | cut -d'"' -f4)
15
+ echo "Plastic update: $CURRENT → $LATEST — run /plastic:update"
16
+ fi
package/package.json ADDED
@@ -0,0 +1,43 @@
1
+ {
2
+ "name": "@zalom/plastic",
3
+ "version": "1.0.0-alpha.1",
4
+ "description": "Intent-driven idea development system for AI coding agents",
5
+ "type": "module",
6
+ "bin": {
7
+ "plastic": "bin/install.js"
8
+ },
9
+ "engines": {
10
+ "node": ">=18.0.0"
11
+ },
12
+ "keywords": [
13
+ "intent-driven",
14
+ "idea-development",
15
+ "zettelkasten",
16
+ "ai-agent",
17
+ "multi-agent",
18
+ "neuroplasticity",
19
+ "claude-code",
20
+ "codex-cli",
21
+ "hermes"
22
+ ],
23
+ "author": {
24
+ "name": "Zlatko Alomerovic",
25
+ "email": "zlatko.alomerovic@gmail.com"
26
+ },
27
+ "license": "MIT",
28
+ "repository": {
29
+ "type": "git",
30
+ "url": "https://github.com/zalom/plastic"
31
+ },
32
+ "homepage": "https://github.com/zalom/plastic",
33
+ "files": [
34
+ "bin/",
35
+ "scripts/",
36
+ "skills/",
37
+ "hooks/",
38
+ "agents/",
39
+ "templates/",
40
+ "PLASTIC.md",
41
+ "deprecations.yml"
42
+ ]
43
+ }
@@ -0,0 +1,40 @@
1
+ #!/usr/bin/env ruby
2
+ # encoding: UTF-8
3
+ # Generate the next available Folgezettel ID for a new intent.
4
+ #
5
+ # Usage:
6
+ # folgezettel-id <store_path> # next root ID
7
+ # folgezettel-id <store_path> <parent_id> # next child of parent
8
+
9
+ store_path = ARGV[0]
10
+ parent_id = ARGV[1] # nil for root
11
+
12
+ unless store_path && Dir.exist?(store_path)
13
+ $stderr.puts "Usage: folgezettel-id <store_path> [parent_id]"
14
+ exit 1
15
+ end
16
+
17
+ existing_dirs = Dir.glob("#{store_path}/*--*").map { |d| File.basename(d).split("--").first }
18
+
19
+ if parent_id.nil? || parent_id.empty?
20
+ roots = existing_dirs.select { |id| id.match?(/\A\d+\z/) }.map(&:to_i)
21
+ next_root = roots.empty? ? 1 : roots.max + 1
22
+ puts next_root.to_s
23
+ else
24
+ depth = parent_id.length
25
+ use_letter = parent_id[-1].match?(/\d/)
26
+
27
+ children = existing_dirs.select do |id|
28
+ id.start_with?(parent_id) && id.length == depth + 1
29
+ end
30
+
31
+ suffixes = children.map { |id| id[-1] }
32
+
33
+ if use_letter
34
+ last = suffixes.select { |s| s.match?(/[a-z]/) }.max || "`"
35
+ puts "#{parent_id}#{last.succ}"
36
+ else
37
+ last = suffixes.select { |s| s.match?(/\d/) }.map(&:to_i).max || 0
38
+ puts "#{parent_id}#{last + 1}"
39
+ end
40
+ end
@@ -0,0 +1,29 @@
1
+ #!/bin/bash
2
+ # Generate a 6-char deterministic hash for an intent name.
3
+ # Auto-detects Ruby or Node.js. Exits with error if neither available.
4
+ #
5
+ # Usage: hash-intent "intent name words"
6
+
7
+ INTENT_NAME="$1"
8
+
9
+ if [ -z "$INTENT_NAME" ]; then
10
+ echo "Usage: hash-intent \"intent name\"" >&2
11
+ exit 1
12
+ fi
13
+
14
+ # Try Ruby first (macOS/Linux default)
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"
17
+ exit 0
18
+ fi
19
+
20
+ # Fallback to Node.js (common on Windows)
21
+ if command -v node &>/dev/null; then
22
+ node -e "const c=require('crypto');console.log(BigInt('0x'+c.createHash('sha256').update(process.argv[1]).digest('hex')).toString(36).slice(0,6))" "$INTENT_NAME"
23
+ exit 0
24
+ fi
25
+
26
+ echo "Error: Neither ruby nor node found. Install one of them." >&2
27
+ echo " macOS/Linux: Ruby is usually pre-installed" >&2
28
+ echo " Windows: Install Node.js or use WSL" >&2
29
+ exit 1
@@ -0,0 +1,130 @@
1
+ #!/usr/bin/env ruby
2
+ # encoding: UTF-8
3
+ # Usage: hook-continue <index_path> <store_root> <mode>
4
+ # Outputs hook JSON for the continue hook. Exits silently if nothing to show.
5
+
6
+ require "json"
7
+ require "date"
8
+ require "yaml"
9
+
10
+ index_path, store_root, mode = ARGV
11
+ exit 0 unless index_path && store_root && mode
12
+
13
+ # --- Parse INDEX.md ---
14
+
15
+ lines = File.readlines(index_path)
16
+ active = []
17
+ future = []
18
+ section = nil
19
+
20
+ lines.each do |line|
21
+ if line.start_with?("## Active")
22
+ section = :active
23
+ next
24
+ elsif line.start_with?("## Future")
25
+ section = :future
26
+ next
27
+ elsif line.start_with?("## ")
28
+ section = nil
29
+ next
30
+ end
31
+
32
+ next unless section && line.strip.start_with?("- [")
33
+
34
+ if section == :active
35
+ active << line.strip
36
+ elsif section == :future
37
+ future << line.strip
38
+ end
39
+ end
40
+
41
+ # --- Detect stale future intents ---
42
+
43
+ stale = []
44
+ stale_days = 3
45
+ config_path = "#{store_root}/config.yml"
46
+ if File.exist?(config_path)
47
+ config = YAML.safe_load(File.read(config_path)) rescue {}
48
+ stale_days = config["stale_threshold_days"] || 3
49
+ end
50
+
51
+ future.each do |f|
52
+ if f =~ /store\/([\w-]+)\//
53
+ dir_name = $1
54
+ intent_file = "#{store_root}/store/#{dir_name}/#{dir_name}.md"
55
+ next unless File.exist?(intent_file)
56
+
57
+ content = File.read(intent_file)
58
+ if content =~ /^created:\s*['"]?(\d{4}-\d{2}-\d{2})/
59
+ age = (Date.today - Date.parse($1)).to_i
60
+ if age >= stale_days
61
+ name = f[/\[([^\]]+)\]/, 1] || "unknown"
62
+ stale << { name: name, age: age, entry: f }
63
+ end
64
+ end
65
+ end
66
+ end
67
+
68
+ # --- Detect current project ---
69
+
70
+ current_project = nil
71
+ if mode == "global"
72
+ projects_path = "#{store_root}/projects.yml"
73
+ if File.exist?(projects_path)
74
+ projects = YAML.safe_load(File.read(projects_path)) rescue {}
75
+ cwd = Dir.pwd
76
+ (projects["projects"] || {}).each do |slug, info|
77
+ project_path = File.expand_path(info["path"])
78
+ if cwd.start_with?(project_path)
79
+ current_project = { "slug" => slug, "parent" => info["parent"], "path" => project_path }
80
+ break
81
+ end
82
+ end
83
+ end
84
+ end
85
+
86
+ # --- Build context ---
87
+
88
+ parts = []
89
+ parts << "PLASTIC CONTINUE — The user wants to resume work.\n"
90
+
91
+ if mode == "global" && current_project
92
+ parts << "Project: #{current_project["slug"]} (#{current_project["path"]})\n"
93
+ parts << "Governing intent: #{current_project["parent"]}\n" if current_project["parent"]
94
+ end
95
+
96
+ if active.any?
97
+ parts << "## Active Intents (work on these first)\n"
98
+ active.each { |a| parts << a }
99
+ parts << ""
100
+ else
101
+ parts << "## No Active Intents\n"
102
+ end
103
+
104
+ if future.any?
105
+ parts << "## Future Intents (offer as next work)\n"
106
+ future.each { |f| parts << f }
107
+ parts << ""
108
+ end
109
+
110
+ if stale.any?
111
+ parts << "## Stale Future Intents\n"
112
+ parts << "These future intents have not been actioned. Ask the user what to do with each:\n"
113
+ stale.each do |s|
114
+ parts << "- #{s[:name]} (#{s[:age]} days old) — options: activate, abandon, or defer to agent (implement / research / ideate)"
115
+ end
116
+ parts << ""
117
+ end
118
+
119
+ parts << "Follow the plastic:continuing skill workflow.\n"
120
+ parts << "1. If active intents exist: read their state and resume\n"
121
+ parts << "2. If no active intents: present future intents as options\n"
122
+ parts << "3. If stale intents exist: surface them and ask user to decide"
123
+
124
+ payload = {
125
+ "hookSpecificOutput" => {
126
+ "hookEventName" => "UserPromptSubmit",
127
+ "additionalContext" => parts.join("\n")
128
+ }
129
+ }
130
+ puts JSON.generate(payload)
@@ -0,0 +1,90 @@
1
+ #!/usr/bin/env ruby
2
+ # encoding: UTF-8
3
+ # Usage: hook-future-intent-check <store_root> <message>
4
+ # Outputs hook JSON when the user's message matches future intent keywords.
5
+ # Exits silently if no matches.
6
+
7
+ require "json"
8
+ require "yaml"
9
+
10
+ store_root, message = ARGV
11
+ exit 0 unless store_root && message
12
+
13
+ index_path = "#{store_root}/INDEX.md"
14
+ exit 0 unless File.exist?(index_path)
15
+
16
+ # --- Find future intent dirs ---
17
+
18
+ lines = File.readlines(index_path)
19
+ future_dirs = []
20
+ section = nil
21
+
22
+ lines.each do |line|
23
+ section = :future if line.start_with?("## Future")
24
+ section = nil if line.start_with?("## ") && !line.start_with?("## Future")
25
+ next unless section == :future && line.strip.start_with?("- [")
26
+
27
+ if line =~ /store\/([\w-]+)\//
28
+ future_dirs << $1
29
+ end
30
+ end
31
+
32
+ exit 0 if future_dirs.empty?
33
+
34
+ # --- Match keywords against message ---
35
+
36
+ matches = []
37
+ message_words = message.split(/\W+/).reject { |w| w.length < 4 }
38
+
39
+ future_dirs.each do |dir|
40
+ intent_path = "#{store_root}/store/#{dir}/#{dir}.md"
41
+ next unless File.exist?(intent_path)
42
+
43
+ content = File.read(intent_path)
44
+
45
+ tags = []
46
+ if content =~ /^tags:\s*\[([^\]]+)\]/
47
+ tags = $1.split(",").map(&:strip).map(&:downcase)
48
+ end
49
+
50
+ intent_name = ""
51
+ if content =~ /^intent:\s*["']?(.+?)["']?\s*$/
52
+ intent_name = $1.downcase
53
+ end
54
+
55
+ intent_id = ""
56
+ if content =~ /^id:\s*["']?([a-z0-9]+)["']?\s*$/
57
+ intent_id = $1
58
+ end
59
+
60
+ keywords = tags + intent_name.split(/\W+/).reject { |w| w.length < 4 }
61
+ keywords = keywords.map(&:downcase).uniq
62
+
63
+ matched_keywords = keywords.select { |kw| message.include?(kw) }
64
+ name_matches = message_words.select { |w| intent_name.include?(w) }
65
+ matched_keywords = (matched_keywords + name_matches).uniq
66
+
67
+ if matched_keywords.length >= 1
68
+ index_line = lines.find { |l| l.include?(dir) }&.strip || "#{intent_id} — #{intent_name}"
69
+ matches << { "index_line" => index_line, "keywords" => matched_keywords }
70
+ end
71
+ end
72
+
73
+ exit 0 if matches.empty?
74
+
75
+ # --- Build context ---
76
+
77
+ parts = []
78
+ parts << "PLASTIC — Future intents related to this message:\n"
79
+ matches.each do |m|
80
+ parts << "#{m["index_line"]} (matched: #{m["keywords"].join(", ")})"
81
+ end
82
+ parts << "\nConsider asking the user if they want to activate any of these, or note the connection."
83
+
84
+ payload = {
85
+ "hookSpecificOutput" => {
86
+ "hookEventName" => "UserPromptSubmit",
87
+ "additionalContext" => parts.join("\n")
88
+ }
89
+ }
90
+ puts JSON.generate(payload)