@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/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
|
-
|
|
54
|
-
|
|
55
|
-
console.error('
|
|
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 '
|
|
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": "
|
|
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": [
|
package/hooks/lock-gate
ADDED
|
@@ -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
|
|
89
|
-
# (plastic-{session_id}.json)
|
|
90
|
-
#
|
|
91
|
-
#
|
|
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
|
-
|
|
96
|
-
|
|
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
|
|
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
|
}
|
package/scripts/agent-report
CHANGED
|
@@ -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")
|
package/scripts/dashboard.rb
CHANGED
|
@@ -32,7 +32,11 @@ def today
|
|
|
32
32
|
s && !s.empty? ? Date.parse(s) : Date.today
|
|
33
33
|
end
|
|
34
34
|
|
|
35
|
-
|
|
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:
|
|
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
|
|
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]) >=
|
|
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]} #{
|
|
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)
|
|
543
|
-
out["research"] = by_created_desc.call(research)
|
|
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
|
-
|
|
611
|
-
|
|
612
|
-
|
|
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
|
|
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
|
|
742
|
+
puts canonical_pretty_json(payload)
|
|
668
743
|
return 0
|
|
669
744
|
end
|
|
670
745
|
|