@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.
Files changed (116) hide show
  1. package/PLASTIC-reference.md +133 -0
  2. package/PLASTIC.md +407 -84
  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 +404 -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/frontmatter_writer.rb +130 -0
  39. package/scripts/lib/graph_rebuild.rb +328 -0
  40. package/scripts/lib/hook_registry.rb +95 -0
  41. package/scripts/lib/insights.rb +86 -0
  42. package/scripts/lib/installer_core.rb +104 -60
  43. package/scripts/lib/link_suggestions.rb +319 -0
  44. package/scripts/lib/links_projection.rb +160 -0
  45. package/scripts/lib/links_section.rb +207 -0
  46. package/scripts/lib/lock.rb +375 -0
  47. package/scripts/lib/power_tools.rb +22 -21
  48. package/scripts/lib/preflight.rb +79 -0
  49. package/scripts/lib/qmd_sync.rb +15 -0
  50. package/scripts/lib/retrieval_gate.rb +211 -0
  51. package/scripts/lib/worktree.rb +384 -0
  52. package/scripts/link-suggest +213 -0
  53. package/scripts/new-intent +138 -29
  54. package/scripts/plastic-lock +164 -0
  55. package/scripts/project-links +287 -0
  56. package/scripts/read-config +4 -0
  57. package/scripts/rebuild-graph +244 -0
  58. package/scripts/spawn-preamble +11 -3
  59. package/scripts/update.rb +16 -7
  60. package/skills/auto/SKILL.md +126 -19
  61. package/skills/auto/references/agent-architecture.md +7 -4
  62. package/skills/auto/references/agent-report-contract.md +36 -1
  63. package/skills/auto/references/end-tail.md +56 -0
  64. package/skills/auto/references/human-report-contract.md +55 -0
  65. package/skills/auto/references/tiers.md +77 -0
  66. package/skills/brainstorming/SKILL.md +7 -34
  67. package/skills/brainstorming/references/design-principles.md +49 -0
  68. package/skills/continuing/SKILL.md +26 -7
  69. package/skills/creating-intent/SKILL.md +13 -28
  70. package/skills/creating-intent/references/lifecycle.md +9 -4
  71. package/skills/creating-project/SKILL.md +11 -74
  72. package/skills/creating-project/references/project-scaffolding.md +97 -0
  73. package/skills/creating-skills/SKILL.md +65 -0
  74. package/skills/creating-skills/evals/evals.json +108 -0
  75. package/skills/creating-skills/references/agents.md +168 -0
  76. package/skills/creating-skills/references/evals.md +41 -0
  77. package/skills/creating-skills/references/hooks.md +248 -0
  78. package/skills/creating-skills/references/progressive-disclosure.md +176 -0
  79. package/skills/creating-skills/references/scripts.md +166 -0
  80. package/skills/creating-skills/references/skills.md +165 -0
  81. package/skills/creating-skills/scripts/scaffold.rb +313 -0
  82. package/skills/dashboard/SKILL.md +13 -11
  83. package/skills/dashboard/references/classification.md +22 -0
  84. package/skills/doctor/SKILL.md +10 -7
  85. package/skills/executing-plan/SKILL.md +4 -4
  86. package/skills/humanizer/SKILL.md +39 -0
  87. package/skills/humanizer/evals/evals.json +70 -0
  88. package/skills/humanizer/references/always-on-snippet.md +9 -0
  89. package/skills/humanizer/references/examples.md +48 -0
  90. package/skills/install/SKILL.md +75 -84
  91. package/skills/intent-curator/SKILL.md +5 -1
  92. package/skills/intent-discovery/SKILL.md +47 -0
  93. package/skills/intent-starting/SKILL.md +130 -0
  94. package/skills/intent-starting/evals/evals.json +117 -0
  95. package/skills/intent-starting/references/boarding-matrix.md +35 -0
  96. package/skills/linking-intents/SKILL.md +48 -12
  97. package/skills/linking-intents/references/zettelkasten.md +7 -0
  98. package/skills/lock/SKILL.md +41 -0
  99. package/skills/managing-index/SKILL.md +6 -0
  100. package/skills/managing-index/references/zettelkasten-linking.md +6 -1
  101. package/skills/releasing/SKILL.md +44 -29
  102. package/skills/releasing/references/promotion-and-tagging.md +60 -0
  103. package/skills/roadmap/SKILL.md +51 -0
  104. package/skills/roadmap/references/file-format.md +91 -0
  105. package/skills/roadmap/references/operations.md +78 -0
  106. package/skills/uninstall/SKILL.md +29 -11
  107. package/skills/update/SKILL.md +34 -23
  108. package/skills/versions/SKILL.md +27 -12
  109. package/skills/writing-plans/SKILL.md +10 -88
  110. package/skills/writing-plans/references/plan-format.md +102 -0
  111. package/templates/config.yml +8 -0
  112. package/templates/outcome.md +3 -0
  113. package/templates/revisions.md +58 -0
  114. package/templates/roadmap.md +30 -0
  115. package/skills/writing-instructions/SKILL.md +0 -159
  116. package/skills/writing-instructions/references/agentskills-spec.md +0 -135
package/bin/plastic.js CHANGED
@@ -50,8 +50,12 @@ try {
50
50
  })
51
51
  } catch (err) {
52
52
  if (err.status) process.exit(err.status)
53
- console.error('Error: Ruby is required to run Plastic.')
54
- console.error(' macOS: Ruby is pre-installed')
55
- console.error(' Linux: sudo apt install ruby / dnf install ruby')
53
+ // Ruby cannot run to print its own message when it is missing, so this
54
+ // mirrors scripts/lib/preflight.rb's FATAL block word for word.
55
+ console.error('Plastic needs Ruby 3.0.0 or newer to run its scripts (found not found).')
56
+ console.error('Install a pinned Ruby with mise:')
57
+ console.error(' curl https://mise.run | sh # only if mise is not installed yet')
58
+ console.error(' mise use --global ruby@3.3')
59
+ console.error('Then re-run the Plastic installer.')
56
60
  process.exit(1)
57
61
  }
package/hooks/code-gate CHANGED
@@ -1,6 +1,15 @@
1
1
  #!/bin/bash
2
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)
3
+ FILE_PATH=$(echo "$INPUT" | ruby -rjson -e '
4
+ data = JSON.parse(STDIN.read)
5
+ ti = data["tool_input"] || data["tool_params"] || {}
6
+ path = ti["file_path"] || ti["notebook_path"] || ti["relative_path"] || ""
7
+ if !path.empty? && !path.start_with?("/")
8
+ root = ti["project_root"] || data["cwd"] || ""
9
+ path = File.join(root, path) unless root.empty?
10
+ end
11
+ puts path
12
+ ' 2>/dev/null)
4
13
 
5
14
  if [ -z "$FILE_PATH" ]; then
6
15
  exit 0
package/hooks/hooks.json CHANGED
@@ -19,7 +19,7 @@
19
19
  ],
20
20
  "PreCompact": [
21
21
  {
22
- "matcher": "manual|auto",
22
+ "matcher": "",
23
23
  "hooks": [
24
24
  {
25
25
  "type": "command",
@@ -31,17 +31,32 @@
31
31
  ],
32
32
  "PreToolUse": [
33
33
  {
34
- "matcher": "Write|Edit|NotebookEdit",
34
+ "matcher": "Write|Edit|NotebookEdit|mcp__serena__replace_content|mcp__serena__replace_symbol_body|mcp__serena__insert_after_symbol|mcp__serena__insert_before_symbol|mcp__serena__safe_delete_symbol|mcp__serena__rename_symbol",
35
35
  "hooks": [
36
36
  {
37
37
  "type": "command",
38
38
  "command": "\"${CLAUDE_PLUGIN_ROOT}/hooks/run-hook\" code-gate",
39
39
  "statusMessage": "Checking lifecycle gate..."
40
+ },
41
+ {
42
+ "type": "command",
43
+ "command": "\"${CLAUDE_PLUGIN_ROOT}/hooks/run-hook\" lock-gate",
44
+ "statusMessage": "Checking lock gate..."
40
45
  }
41
46
  ]
42
47
  },
43
48
  {
44
- "matcher": "Write",
49
+ "matcher": "Write|Edit",
50
+ "hooks": [
51
+ {
52
+ "type": "command",
53
+ "command": "\"${CLAUDE_PLUGIN_ROOT}/hooks/run-hook\" savepoint-pre",
54
+ "statusMessage": "Recording stage start..."
55
+ }
56
+ ]
57
+ },
58
+ {
59
+ "matcher": "Write|Edit|mcp__serena__replace_content|mcp__serena__replace_symbol_body|mcp__serena__insert_after_symbol|mcp__serena__insert_before_symbol|mcp__serena__safe_delete_symbol|mcp__serena__rename_symbol",
45
60
  "hooks": [
46
61
  {
47
62
  "type": "command",
@@ -59,6 +74,16 @@
59
74
  "statusMessage": "Checking lifecycle gate..."
60
75
  }
61
76
  ]
77
+ },
78
+ {
79
+ "matcher": "Bash|Read|Grep|Glob",
80
+ "hooks": [
81
+ {
82
+ "type": "command",
83
+ "command": "\"${CLAUDE_PLUGIN_ROOT}/hooks/run-hook\" retrieval-gate",
84
+ "statusMessage": "Checking retrieval gate..."
85
+ }
86
+ ]
62
87
  }
63
88
  ],
64
89
  "PostToolUse": [
@@ -0,0 +1,21 @@
1
+ #!/bin/bash
2
+ INPUT=$(cat)
3
+ FILE_PATH=$(echo "$INPUT" | ruby -rjson -e '
4
+ data = JSON.parse(STDIN.read)
5
+ ti = data["tool_input"] || data["tool_params"] || {}
6
+ path = ti["file_path"] || ti["notebook_path"] || ti["relative_path"] || ""
7
+ if !path.empty? && !path.start_with?("/")
8
+ root = ti["project_root"] || data["cwd"] || ""
9
+ path = File.join(root, path) unless root.empty?
10
+ end
11
+ puts path
12
+ ' 2>/dev/null)
13
+
14
+ if [ -z "$FILE_PATH" ]; then
15
+ exit 0
16
+ fi
17
+
18
+ SESSION_ID=$(echo "$INPUT" | ruby -rjson -e 'data = JSON.parse(STDIN.read); puts data.dig("session_id") || ""' 2>/dev/null)
19
+
20
+ SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
21
+ ruby "$SCRIPT_DIR/../scripts/hook-lock-gate" "$FILE_PATH" "$SESSION_ID"
@@ -0,0 +1,10 @@
1
+ #!/bin/bash
2
+ # Retrieval-gate hook launcher (intent 84, Lever 2). No-op when there is no
3
+ # global store. Passes the PreToolUse JSON through on stdin (exec inherits it)
4
+ # and the plastic_home as ARGV[0], mirroring the qmd-search launcher.
5
+ GLOBAL_INDEX="$HOME/.plastic/INDEX.md"
6
+ if [ ! -f "$GLOBAL_INDEX" ]; then
7
+ exit 0
8
+ fi
9
+ SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
10
+ exec ruby "$SCRIPT_DIR/../scripts/hook-retrieval-gate" "$HOME/.plastic"
@@ -0,0 +1,10 @@
1
+ #!/bin/bash
2
+ INPUT=$(cat)
3
+ FILE_PATH=$(echo "$INPUT" | ruby -rjson -e 'data = JSON.parse(STDIN.read); puts data.dig("tool_params", "file_path") || data.dig("tool_input", "file_path") || ""' 2>/dev/null)
4
+
5
+ if [ -z "$FILE_PATH" ]; then
6
+ exit 0
7
+ fi
8
+
9
+ SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
10
+ ruby "$SCRIPT_DIR/../scripts/hook-savepoint-pre" "$FILE_PATH"
package/hooks/statusline CHANGED
@@ -85,15 +85,57 @@ fi
85
85
  INDEX_DIR=$(dirname "$INDEX")
86
86
 
87
87
  # --- Current-session work-unit: the live bridge wins over savepoint recency ---
88
- # The statusline receives the real session_id on stdin; the session's bridge
89
- # (plastic-{session_id}.json) is the authoritative "what THIS session is driving".
90
- # This beats the shared, savepoint-recency heuristic so parallel sessions on one
91
- # store no longer overwrite each other's line. grep/sed only - no jq, no ruby.
88
+ # The statusline receives the real session_id on stdin. A session can now own
89
+ # SEVERAL per-intent bridges at once (intent 131, plastic-{session_id}--{id}.json),
90
+ # one per concurrent delivery, so the statusline must pick which of them to show:
91
+ # prefer a candidate whose worktree.code (or intent dir) prefixes REAL_CWD, else
92
+ # the newest by mtime (ls -t is 3.2-safe); tolerate a legacy single-key
93
+ # plastic-{session_id}.json when no per-intent file exists. This beats the shared,
94
+ # savepoint-recency heuristic so parallel sessions on one store no longer
95
+ # overwrite each other's line. grep/sed/ls only - no jq, no ruby.
92
96
  WORK=""
93
97
  SID=$(json_str "session_id")
94
98
  if [ -n "$SID" ]; then
95
- BRIDGE="${PLASTIC_TMP:-/tmp}/plastic-${SID}.json"
96
- if [ -f "$BRIDGE" ]; then
99
+ BR_TMP="${PLASTIC_TMP:-/tmp}"
100
+ BRIDGE=""
101
+ CANDIDATES=$(ls -1t "${BR_TMP}"/plastic-"${SID}"--*.json 2>/dev/null)
102
+ if [ -n "$CANDIDATES" ]; then
103
+ while IFS= read -r cand; do
104
+ [ -z "$cand" ] && continue
105
+ WT_BLOCK=$(sed -n '/"worktree"[[:space:]]*:[[:space:]]*{/,/}/p' "$cand" 2>/dev/null)
106
+ WC=$(printf '%s\n' "$WT_BLOCK" | grep -o '"code"[[:space:]]*:[[:space:]]*"[^"]*"' | head -1 \
107
+ | sed 's/.*"code"[[:space:]]*:[[:space:]]*"//;s/"$//')
108
+ IN_BLOCK=$(sed -n '/"intent"[[:space:]]*:[[:space:]]*{/,/}/p' "$cand" 2>/dev/null)
109
+ ISTORE=$(printf '%s\n' "$IN_BLOCK" | grep -o '"store"[[:space:]]*:[[:space:]]*"[^"]*"' | head -1 \
110
+ | sed 's/.*"store"[[:space:]]*:[[:space:]]*"//;s/"$//')
111
+ IDIR=$(printf '%s\n' "$IN_BLOCK" | grep -o '"dir"[[:space:]]*:[[:space:]]*"[^"]*"' | head -1 \
112
+ | sed 's/.*"dir"[[:space:]]*:[[:space:]]*"//;s/"$//')
113
+ MATCH=""
114
+ if [ -n "$WC" ] && [ -n "$REAL_CWD" ]; then
115
+ case "$REAL_CWD" in "$WC"|"$WC"/*) MATCH=1 ;; esac
116
+ fi
117
+ if [ -z "$MATCH" ] && [ -n "$ISTORE" ] && [ -n "$IDIR" ] && [ -n "$REAL_CWD" ]; then
118
+ IPATH="$ISTORE/$IDIR"
119
+ case "$REAL_CWD" in "$IPATH"|"$IPATH"/*) MATCH=1 ;; esac
120
+ fi
121
+ if [ -n "$MATCH" ]; then
122
+ BRIDGE="$cand"
123
+ break
124
+ fi
125
+ done <<BRIDGE_CAND_EOF
126
+ $CANDIDATES
127
+ BRIDGE_CAND_EOF
128
+ # No cwd/worktree signal on any candidate: fail open to the newest (ls -t
129
+ # already sorted CANDIDATES newest-first).
130
+ [ -z "$BRIDGE" ] && BRIDGE=$(printf '%s\n' "$CANDIDATES" | head -1)
131
+ else
132
+ # Legacy tolerance: no per-intent bridge for this session, but an old
133
+ # single-key file may still exist.
134
+ LEGACY="${BR_TMP}/plastic-${SID}.json"
135
+ [ -f "$LEGACY" ] && BRIDGE="$LEGACY"
136
+ fi
137
+
138
+ if [ -n "$BRIDGE" ] && [ -f "$BRIDGE" ]; then
97
139
  # Bridge JSON is pretty-printed (one field per line). Scope extraction to the
98
140
  # "intent" object so a sibling top-level "id"/"name" can never win (the intent
99
141
  # object holds only string fields, so the first "}" closes it). grep/sed only.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zalom/plastic",
3
- "version": "1.0.0-beta.8",
3
+ "version": "1.0.0",
4
4
  "description": "Intent-driven idea development system for AI coding agents",
5
5
  "type": "module",
6
6
  "bin": {
@@ -38,6 +38,7 @@
38
38
  "agents/",
39
39
  "templates/",
40
40
  "PLASTIC.md",
41
+ "PLASTIC-reference.md",
41
42
  "deprecations.yml"
42
43
  ]
43
44
  }
@@ -108,6 +108,26 @@ def outcome_line(intent_dir)
108
108
  "(no outcome yet)"
109
109
  end
110
110
 
111
+ # The insights line: the LAST line of the intent file's `## Insights` section
112
+ # (the newest nugget, since entries are append-only newest-at-bottom), else
113
+ # `(none)`. Pure read of the intent dir, no clock.
114
+ def insights_line(intent_dir)
115
+ ifile = Bridge.intent_file(intent_dir)
116
+ return "(none)" unless File.exist?(ifile)
117
+
118
+ lines = File.read(ifile).split("\n", -1)
119
+ idx = lines.index { |l| l.strip == "## Insights" }
120
+ return "(none)" if idx.nil?
121
+
122
+ last = nil
123
+ (idx + 1).upto(lines.length - 1) do |i|
124
+ break if lines[i].start_with?("## ")
125
+
126
+ last = lines[i] unless lines[i].strip.empty?
127
+ end
128
+ last || "(none)"
129
+ end
130
+
111
131
  intent_dir_arg, role = parse_args(ARGV)
112
132
 
113
133
  if intent_dir_arg.nil? || intent_dir_arg.empty?
@@ -137,6 +157,7 @@ lines << "Status: synthesized (filesystem-derived; no agent-authored report)"
137
157
  lines << "Artifacts present: #{artifacts_str}"
138
158
  lines << "Checklist: #{checklist_progress(intent_dir)}"
139
159
  lines << "Outcome: #{outcome_line(intent_dir)}"
160
+ lines << "Insights: #{insights_line(intent_dir)}"
140
161
  lines << "=== end report ==="
141
162
 
142
163
  puts lines.join("\n")
@@ -32,7 +32,11 @@ def today
32
32
  s && !s.empty? ? Date.parse(s) : Date.today
33
33
  end
34
34
 
35
- STALE_DAYS = 14
35
+ # A generic "about a month" threshold, not tuned to any one store's item count.
36
+ # Deliberately independent from plastic-continuing's separate stale_threshold_days
37
+ # config: that one is a proactive boot-time triage nudge, this is a board annotation.
38
+ # Unifying the two is a follow-up, not this intent.
39
+ STALE_DAYS = 30
36
40
 
37
41
  # ---------------------------------------------------------------------------
38
42
  # Parsing — reuses the conventions in scripts/doctor.rb
@@ -105,6 +109,26 @@ def last_accessed_at(dir, created)
105
109
  ""
106
110
  end
107
111
 
112
+ # True iff the savepoint ledger's last non-blank line shows real post-birth
113
+ # activity, not just the one-line birth stamp every intent gets at creation
114
+ # (scripts/new-intent's Bridge.append_savepoint call, stage "What"). Reads the
115
+ # last line, extracts the stage token (second whitespace-separated field, same
116
+ # ledger shape last_accessed_at already parses), and treats any stage other
117
+ # than "What" as progress. Returns false when the file is missing/empty.
118
+ def savepoint_shows_progress?(path)
119
+ return false unless File.exist?(path)
120
+ last_line = nil
121
+ File.readlines(path).reverse_each do |line|
122
+ stripped = line.strip
123
+ next if stripped.empty?
124
+ last_line = stripped
125
+ break
126
+ end
127
+ return false unless last_line
128
+ stage = last_line.split(/\s+/)[1]
129
+ !stage.nil? && stage != "What"
130
+ end
131
+
108
132
  # Parse one intent directory into a raw record.
109
133
  def parse_intent(store_info, dir_name, status_index)
110
134
  dir = File.join(store_info[:store], dir_name)
@@ -113,7 +137,6 @@ def parse_intent(store_info, dir_name, status_index)
113
137
  return nil unless fm && fm["id"]
114
138
 
115
139
  id = fm["id"].to_s
116
- has = ->(f) { File.exist?(File.join(dir, f)) }
117
140
  # Sentinel-aware presence for lifecycle files (intent 60b): a scaffolded
118
141
  # placeholder spec/plan/checklist/outcome reads as absent, so a freshly
119
142
  # scaffolded intent reports What/Why and is never marked completed/advanced.
@@ -143,7 +166,7 @@ def parse_intent(store_info, dir_name, status_index)
143
166
  plan: real.("plan.md"),
144
167
  checklist: real.("checklist.md"),
145
168
  outcome: real.("outcome.md"),
146
- savepoint: has.("savepoint.md"),
169
+ savepoint: savepoint_shows_progress?(File.join(dir, "savepoint.md")),
147
170
  checklist_partial: real.("checklist.md") && checklist_partially_done?(File.join(dir, "checklist.md")),
148
171
  body_has_context: body.include?("## Context"),
149
172
  last_accessed_at: last_accessed_at(dir, (fm["created"].to_s rescue "")),
@@ -163,6 +186,7 @@ end
163
186
  def load_all
164
187
  all = []
165
188
  done_ids = {}
189
+ completed_on_map = {}
166
190
  stores.each do |si|
167
191
  idx = {
168
192
  active: index_section_ids(si[:index], "## Active"),
@@ -176,11 +200,12 @@ def load_all
176
200
  rec[:completed_on] = comp[rec[:id]] || ""
177
201
  all << rec
178
202
  done_ids[[rec[:scope], rec[:id]]] = true if rec[:status] == "completed"
203
+ completed_on_map[[rec[:scope], rec[:id]]] = comp[rec[:id]] if comp[rec[:id]] && !comp[rec[:id]].empty?
179
204
  end
180
205
  end
181
206
  referenced = {}
182
207
  all.each { |r| r[:sources].each { |s| referenced[[r[:scope], s]] = true } }
183
- [all, done_ids, referenced]
208
+ [all, done_ids, referenced, completed_on_map]
184
209
  end
185
210
 
186
211
  # ---------------------------------------------------------------------------
@@ -214,11 +239,14 @@ end
214
239
 
215
240
  # Effort -> :small | :big
216
241
  # Small when the work is bounded: a non-implementation type, an already-scoped intent
217
- # (plan/checklist exists), or a deep refinement branch. Otherwise big.
242
+ # (plan/checklist exists), or a branch id. By Plastic's own Zettelkasten convention, a
243
+ # branch id is a narrower refinement of its parent, so any branch (not only deep
244
+ # sub-branches) defaults to smaller effort than an untouched root idea; root ids
245
+ # (`root_intent?`) are unaffected since `folgezettel_depth` on a bare number is always 1.
218
246
  def effort_of(rec, type)
219
247
  return :small if %w[research exploration bugfix].include?(type)
220
248
  return :small if rec[:plan] || rec[:checklist]
221
- return :small if folgezettel_depth(rec[:id]) >= 4
249
+ return :small if folgezettel_depth(rec[:id]) >= 2
222
250
  :big
223
251
  end
224
252
 
@@ -238,11 +266,12 @@ def value_of(rec, referenced = {})
238
266
  :low
239
267
  end
240
268
 
241
- def flags_of(rec, done_ids)
269
+ def flags_of(rec, done_ids, completed_on_map = {})
242
270
  flags = []
243
271
  flags << "in-progress" if rec[:savepoint] || rec[:checklist_partial]
244
272
  if rec[:status] == "future" && !rec[:sources].empty? &&
245
- rec[:sources].all? { |s| done_ids[[rec[:scope], s]] }
273
+ rec[:sources].all? { |s| done_ids[[rec[:scope], s]] } &&
274
+ genuine_wait?(rec, completed_on_map)
246
275
  flags << "unblocked"
247
276
  end
248
277
  age = stale_age(rec)
@@ -250,6 +279,22 @@ def flags_of(rec, done_ids)
250
279
  flags
251
280
  end
252
281
 
282
+ # True iff at least one declared source's completion date is strictly later
283
+ # than this intent's own `created` date — i.e. the intent actually waited on
284
+ # something, rather than being born already-satisfied (the common case: a
285
+ # branch's declared source is almost always finished before the branch exists,
286
+ # since the child is created from the parent's own lifecycle).
287
+ def genuine_wait?(rec, completed_on_map)
288
+ created_date = (Date.parse(rec[:created]) rescue nil)
289
+ return false unless created_date
290
+ rec[:sources].any? do |s|
291
+ source_date_str = completed_on_map[[rec[:scope], s]]
292
+ next false unless source_date_str
293
+ source_date = (Date.parse(source_date_str) rescue nil)
294
+ source_date && source_date > created_date
295
+ end
296
+ end
297
+
253
298
  def stale_age(rec)
254
299
  return nil if rec[:created].nil? || rec[:created].empty?
255
300
  (today - Date.parse(rec[:created])).to_i
@@ -274,13 +319,13 @@ def disposition_of(type, quadrant)
274
319
  end
275
320
  end
276
321
 
277
- def classify(rec, done_ids, referenced = {})
322
+ def classify(rec, done_ids, referenced = {}, completed_on_map = {})
278
323
  type = intent_type(rec)
279
324
  value = value_of(rec, referenced)
280
325
  effort = effort_of(rec, type)
281
326
  quadrant = QUADRANTS[[value, effort]]
282
327
  disposition = disposition_of(type, quadrant)
283
- flags = flags_of(rec, done_ids)
328
+ flags = flags_of(rec, done_ids, completed_on_map)
284
329
  rec.merge(
285
330
  type: type, value: value, effort: effort, quadrant: quadrant,
286
331
  lifecycle: lifecycle_stage(rec), flags: flags, disposition: disposition,
@@ -329,6 +374,13 @@ end
329
374
 
330
375
  CELL_CAP = 6
331
376
 
377
+ # Markdown-board caps (Task 5, D6/D7): the ASCII renderer already caps via CELL_CAP/
378
+ # cap_cell, but the Markdown board's matrix_data quadrants and the project board's
379
+ # active/future lists had no cap and no per-line truncation, so a large store printed
380
+ # hundreds of full-length lines. These two constants fix that on the Markdown side only.
381
+ MATRIX_DATA_CAP = 8
382
+ INTENT_LINE_MAX_CHARS = 120
383
+
332
384
  def matrix(records, scope_tag: false)
333
385
  cells = { "quick_win" => [], "next_big" => [], "defer" => [], "triage" => [] }
334
386
  research = []
@@ -526,8 +578,23 @@ end
526
578
 
527
579
  def intent_line(rec, bullet)
528
580
  note = rec[:status] == "active" ? " (#{rec[:lifecycle].to_s.capitalize})" : ""
581
+ text = rec[:intent].to_s
582
+ text = "#{text[0, INTENT_LINE_MAX_CHARS]}…" if text.length > INTENT_LINE_MAX_CHARS
529
583
  { id: rec[:id], intent: rec[:intent], created: rec[:created], bullet: bullet,
530
- scope: rec[:scope], line: "#{bullet} #{rec[:id]} #{rec[:intent]}#{note}".rstrip }
584
+ scope: rec[:scope], line: "#{bullet} #{rec[:id]} #{text}#{note}".rstrip }
585
+ end
586
+
587
+ # Cap a raw record list to MATRIX_DATA_CAP entries, then map to intent_line-shaped
588
+ # hashes, appending a plain "+N more" line (no id, not a real record) when truncated.
589
+ # Caps the record list first so the "+N more" entry never goes through intent_line.
590
+ def cap_lines(list, bullet)
591
+ capped = list.first(MATRIX_DATA_CAP)
592
+ lines = capped.map { |r| intent_line(r, bullet) }
593
+ if list.size > MATRIX_DATA_CAP
594
+ lines << { id: "", intent: "", created: "", bullet: bullet, scope: "",
595
+ line: "#{bullet} +#{list.size - MATRIX_DATA_CAP} more" }
596
+ end
597
+ lines
531
598
  end
532
599
 
533
600
  def matrix_data(records)
@@ -539,8 +606,8 @@ def matrix_data(records)
539
606
  end
540
607
  by_created_desc = ->(list) { list.sort_by { |r| invert_ts(r[:created]) } }
541
608
  out = {}
542
- cells.each { |q, list| out[q] = by_created_desc.call(list).map { |r| intent_line(r, QUADRANT_BULLET[q]) } }
543
- out["research"] = by_created_desc.call(research).map { |r| intent_line(r, "🔬") }
609
+ cells.each { |q, list| out[q] = cap_lines(by_created_desc.call(list), QUADRANT_BULLET[q]) }
610
+ out["research"] = cap_lines(by_created_desc.call(research), "🔬")
544
611
  out
545
612
  end
546
613
 
@@ -606,12 +673,10 @@ def render_data_project(records, slug)
606
673
  recently_worked: recently_worked(records, project_scope: scope),
607
674
  matrix: matrix_data(matrix_pool),
608
675
  counts: counts_of(scoped),
609
- active: scoped.select { |r| r[:status] == "active" }
610
- .sort_by { |r| invert_ts(r[:last_accessed_at]) }
611
- .map { |r| intent_line(r, STATUS_GLYPH["active"]) },
612
- future: scoped.select { |r| r[:status] == "future" }
613
- .sort_by { |r| invert_ts(r[:created]) }
614
- .map { |r| intent_line(r, STATUS_GLYPH["future"]) } }
676
+ active: cap_lines(scoped.select { |r| r[:status] == "active" }
677
+ .sort_by { |r| invert_ts(r[:last_accessed_at]) }, STATUS_GLYPH["active"]),
678
+ future: cap_lines(scoped.select { |r| r[:status] == "future" }
679
+ .sort_by { |r| invert_ts(r[:created]) }, STATUS_GLYPH["future"]) }
615
680
  end
616
681
 
617
682
  # ---------------------------------------------------------------------------
@@ -639,18 +704,28 @@ end
639
704
  # CLI
640
705
  # ---------------------------------------------------------------------------
641
706
 
707
+ # JSON.pretty_generate renders empty arrays/objects as multi-line ("[\n\n ]") on
708
+ # some json gem versions and single-line ("[]") on others, so the same payload
709
+ # serializes differently across environments. Collapse both to the single-line
710
+ # form so dashboard JSON output is byte-identical everywhere. The \n requirement
711
+ # in each pattern is what makes this safe: a raw newline is illegal inside a JSON
712
+ # string, so neither pattern can ever match inside string content.
713
+ def canonical_pretty_json(payload)
714
+ JSON.pretty_generate(payload).gsub(/\[\s*\n\s*\]/, "[]").gsub(/\{\s*\n\s*\}/, "{}")
715
+ end
716
+
642
717
  def main(argv)
643
718
  json = argv.delete("--json")
644
719
  data = argv.delete("--data")
645
720
  mode = argv.shift || "continue"
646
721
  slug = argv.shift
647
722
 
648
- raw, done_ids, referenced = load_all
649
- records = raw.map { |r| classify(r, done_ids, referenced) }
723
+ raw, done_ids, referenced, completed_on_map = load_all
724
+ records = raw.map { |r| classify(r, done_ids, referenced, completed_on_map) }
650
725
 
651
726
  if data
652
727
  payload = mode == "project" ? render_data_project(records, slug) : render_data_global(records)
653
- puts JSON.pretty_generate(payload)
728
+ puts canonical_pretty_json(payload)
654
729
  return 0
655
730
  end
656
731
 
@@ -664,7 +739,7 @@ def main(argv)
664
739
  # per-scope boards (keeping the all-scopes auto-mode contract stable).
665
740
  payload[:store_health] = store_health(slug) if mode == "project"
666
741
  payload[:store_health] = store_health(:global) if mode == "continue"
667
- puts JSON.pretty_generate(payload)
742
+ puts canonical_pretty_json(payload)
668
743
  return 0
669
744
  end
670
745