@jenga-ai/agent 1.3.0 → 2.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 (76) hide show
  1. package/README.md +97 -92
  2. package/agents/developer.md +9 -8
  3. package/agents/scrum-master.md +57 -23
  4. package/agents/tester.md +51 -5
  5. package/hooks/on_session_end.sh +13 -1
  6. package/lib/generate-agent-context.js +18 -1
  7. package/lib/generate-copilot-instructions.js +18 -1
  8. package/lib/generate-skill-allow-list.js +191 -0
  9. package/lib/skill-allow-list.json +43 -0
  10. package/package.json +18 -4
  11. package/scripts/apply-j-prefix.sh +230 -0
  12. package/scripts/consume-context-digest.sh +103 -0
  13. package/scripts/postinstall.js +25 -0
  14. package/scripts/sweep-stale-context-digests.sh +132 -0
  15. package/scripts/validate-board.sh +5 -0
  16. package/scripts/write-context-digest.sh +230 -0
  17. package/skills/brainstorm/SKILL.md +1 -1
  18. package/skills/btw/SKILL.md +1 -1
  19. package/skills/clearify/SKILL.md +1 -1
  20. package/skills/close-story/SKILL.md +78 -6
  21. package/skills/close-story/scripts/check-privatized.sh +345 -0
  22. package/skills/commit/SKILL.md +1 -1
  23. package/skills/continue/SKILL.md +1 -1
  24. package/skills/deep-dive/SKILL.md +1 -1
  25. package/skills/dev-done/SKILL.md +1 -1
  26. package/skills/distribute/SKILL.md +1 -1
  27. package/skills/do/SKILL.md +100 -10
  28. package/skills/doc/README.md +155 -0
  29. package/skills/doc/SKILL.md +43 -13
  30. package/skills/doc/authoring-notes.md +72 -0
  31. package/skills/doc/scripts/resolve_last_update.py +149 -0
  32. package/skills/doc-sync/SKILL.md +1 -1
  33. package/skills/dooo/SKILL.md +1 -1
  34. package/skills/error/SKILL.md +1 -1
  35. package/skills/evaluate/SKILL.md +1 -1
  36. package/skills/examplify/SKILL.md +1 -1
  37. package/skills/help/SKILL.md +1 -1
  38. package/skills/idea/SKILL.md +1 -1
  39. package/skills/improve/SKILL.md +1 -1
  40. package/skills/init/SKILL.md +1 -1
  41. package/skills/init/assets/scope-thresholds_template.json +3 -3
  42. package/skills/j-init/SKILL.md +168 -0
  43. package/skills/j-init/assets/.gitignore_template +15 -0
  44. package/skills/j-init/assets/PROJECT_SUMMARY_template.md +13 -0
  45. package/skills/j-init/assets/directory_structure.txt +14 -0
  46. package/skills/j-init/assets/scope-thresholds_template.json +7 -0
  47. package/skills/j-init/assets/strategy_stub_template.md +38 -0
  48. package/skills/j-init/assets/test-config_template.json +4 -0
  49. package/skills/j-init/assets/workflow_template.json +30 -0
  50. package/skills/j-init/scripts/apply-project-visibility.sh +176 -0
  51. package/skills/j-init/scripts/detect-existing-codebase.sh +166 -0
  52. package/skills/j-init/scripts/init.sh +116 -0
  53. package/skills/jbp/SKILL.md +1 -1
  54. package/skills/jenga/SKILL.md +1 -1
  55. package/skills/jenga/scripts/render-confirmation.sh +55 -18
  56. package/skills/jenga-permission-level/SKILL.md +1 -1
  57. package/skills/lgtm/SKILL.md +1 -1
  58. package/skills/pi-plan/SKILL.md +1 -1
  59. package/skills/proceed/SKILL.md +1 -1
  60. package/skills/publish/SKILL.md +1 -1
  61. package/skills/publish/adapters/npm-ci.md +26 -4
  62. package/skills/publish/scripts/npm_ci_pipeline.sh +21 -1
  63. package/skills/reconcile/SKILL.md +1 -1
  64. package/skills/reconcile-origin/SKILL.md +1 -1
  65. package/skills/redo/SKILL.md +1 -1
  66. package/skills/skillify/SKILL.md +1 -1
  67. package/skills/spinoff/SKILL.md +1 -1
  68. package/skills/status/SKILL.md +1 -1
  69. package/skills/todo/SKILL.md +40 -3
  70. package/skills/todo/scripts/add_trivial_task.sh +216 -0
  71. package/skills/todo/scripts/update_story_tasks.py +87 -0
  72. package/skills/uncharted/SKILL.md +1 -1
  73. package/skills/wtf/SKILL.md +1 -1
  74. package/templates/SCRUM_BOARD_SCHEMA.md +33 -2
  75. package/templates/agent-context.md.tpl +32 -9
  76. package/templates/copilot-instructions.md.tpl +25 -8
@@ -0,0 +1,230 @@
1
+ #!/usr/bin/env bash
2
+ # scripts/apply-j-prefix.sh — mechanical j: prefix rename for skill invocation
3
+ #
4
+ # Implements the mechanism decided in docs/skill-authoring.md's "Invocation Convention"
5
+ # section (E50_S01_T01):
6
+ #
7
+ # 1. Frontmatter-only rename. For every skills/<name>/SKILL.md, rewrites the
8
+ # frontmatter `name:` field from `<name>` to `j:<name>`. Directory names are
9
+ # left untouched — ":" is not a Windows-safe filename character, and Claude
10
+ # Code's native skill resolution is a literal-string match against the
11
+ # discovery-path directory name, independent of frontmatter content (see the
12
+ # docs section for the full investigation and citation).
13
+ # 2. Bare-form prose rewrite. For every agents/*.md file, rewrites bare
14
+ # "/<name>" invocation mentions in prose to "j:<name>", for every skill name
15
+ # discovered in step 1 — longest-name-first, boundary-safe (skill names
16
+ # contain hyphens, so a naive `\b` regex would misfire — e.g. it would
17
+ # falsely match "/doc" inside "/doc-sync"; this only rewrites a match when
18
+ # the character immediately before/after it is not itself an identifier
19
+ # character: letter, digit, "-", or ":").
20
+ #
21
+ # This script does NOT remove or disable the old bare "/<name>" form. The
22
+ # migration decision recorded alongside the mechanism above is "alias" — both
23
+ # forms keep resolving. This script only ever writes inside skills/ and
24
+ # agents/; it never touches .claude/ or .agents/ (generated mirrors — see
25
+ # CLAUDE.md's Source of Truth rule) and makes no other filesystem change.
26
+ #
27
+ # Usage:
28
+ # scripts/apply-j-prefix.sh [--dry-run] [--skills-only | --agents-only]
29
+ #
30
+ # --dry-run Print the full change list without writing anything.
31
+ # Exit code is 0 whether or not changes were found (unless
32
+ # --skills-only/--agents-only surface a hard error, e.g. a
33
+ # frontmatter `name:` that doesn't match its directory).
34
+ # --skills-only Only process skills/*/SKILL.md frontmatter (step 1).
35
+ # --agents-only Only process agents/*.md prose (step 2).
36
+ #
37
+ # Idempotent: already-migrated skills (name already starts with "j:") are
38
+ # silently skipped, not re-applied or reported as an error.
39
+
40
+ set -euo pipefail
41
+
42
+ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
43
+ # shellcheck source=lib/resolve-project-dir.sh
44
+ source "$SCRIPT_DIR/../lib/resolve-project-dir.sh"
45
+
46
+ PROJECT_DIR="$JENGA_PROJECT_DIR"
47
+ SKILLS_DIR="$PROJECT_DIR/skills"
48
+ AGENTS_DIR="$PROJECT_DIR/agents"
49
+
50
+ DRY_RUN=0
51
+ DO_SKILLS=1
52
+ DO_AGENTS=1
53
+
54
+ for arg in "$@"; do
55
+ case "$arg" in
56
+ --dry-run) DRY_RUN=1 ;;
57
+ --skills-only) DO_AGENTS=0 ;;
58
+ --agents-only) DO_SKILLS=0 ;;
59
+ -h|--help)
60
+ echo "Usage: $(basename "$0") [--dry-run] [--skills-only | --agents-only]"
61
+ exit 0
62
+ ;;
63
+ *)
64
+ echo "❌ Unknown argument: $arg" >&2
65
+ echo "Usage: $(basename "$0") [--dry-run] [--skills-only | --agents-only]" >&2
66
+ exit 1
67
+ ;;
68
+ esac
69
+ done
70
+
71
+ export DRY_RUN DO_SKILLS DO_AGENTS SKILLS_DIR AGENTS_DIR
72
+
73
+ python3 - <<'PY'
74
+ import os
75
+ import re
76
+ import sys
77
+
78
+ skills_dir = os.environ["SKILLS_DIR"]
79
+ agents_dir = os.environ["AGENTS_DIR"]
80
+ dry_run = os.environ["DRY_RUN"] == "1"
81
+ do_skills = os.environ["DO_SKILLS"] == "1"
82
+ do_agents = os.environ["DO_AGENTS"] == "1"
83
+
84
+ changes = []
85
+ errors = []
86
+
87
+ # ---- Step 1: discover skills + (optionally) rewrite frontmatter name: field ----
88
+ skill_names = [] # bare directory names, in sorted order
89
+ name_re = re.compile(r'^name:\s*(.+?)\s*$')
90
+
91
+ if os.path.isdir(skills_dir):
92
+ for entry in sorted(os.listdir(skills_dir)):
93
+ skill_path = os.path.join(skills_dir, entry, "SKILL.md")
94
+ if not os.path.isfile(skill_path):
95
+ continue # not a skill dir (e.g. skills/index/ has no SKILL.md)
96
+
97
+ with open(skill_path, "r", encoding="utf-8") as f:
98
+ lines = f.readlines()
99
+
100
+ if not lines or lines[0].strip() != "---":
101
+ errors.append(f"{skill_path}: no frontmatter block found")
102
+ continue
103
+
104
+ close_idx = None
105
+ for i in range(1, len(lines)):
106
+ if lines[i].strip() == "---":
107
+ close_idx = i
108
+ break
109
+ if close_idx is None:
110
+ errors.append(f"{skill_path}: unterminated frontmatter block")
111
+ continue
112
+
113
+ name_idx = None
114
+ current_name = None
115
+ for i in range(1, close_idx):
116
+ m = name_re.match(lines[i])
117
+ if m:
118
+ name_idx = i
119
+ current_name = m.group(1).strip('"\'')
120
+ break
121
+
122
+ if name_idx is None:
123
+ errors.append(f"{skill_path}: no 'name:' field in frontmatter")
124
+ continue
125
+
126
+ skill_names.append(entry)
127
+
128
+ if current_name.startswith("j:"):
129
+ continue # already migrated — not an error, nothing to do
130
+
131
+ if current_name != entry:
132
+ errors.append(
133
+ f"{skill_path}: frontmatter name '{current_name}' does not match "
134
+ f"directory name '{entry}' — skipped for safety"
135
+ )
136
+ continue
137
+
138
+ if not do_skills:
139
+ continue
140
+
141
+ new_line = f"name: j:{entry}\n"
142
+ changes.append({
143
+ "type": "frontmatter",
144
+ "file": skill_path,
145
+ "before": lines[name_idx].rstrip("\n"),
146
+ "after": new_line.rstrip("\n"),
147
+ })
148
+
149
+ if not dry_run:
150
+ lines[name_idx] = new_line
151
+ with open(skill_path, "w", encoding="utf-8") as f:
152
+ f.writelines(lines)
153
+ elif do_skills:
154
+ errors.append(f"skills directory not found: {skills_dir}")
155
+
156
+ # ---- Step 2: rewrite bare "/<name>" prose mentions in agents/*.md ----
157
+ if do_agents and os.path.isdir(agents_dir):
158
+ # Longest name first so "/jenga-permission-level" is matched (and
159
+ # consumed) before the shorter "/jenga" gets a chance to.
160
+ ordered_names = sorted(skill_names, key=len, reverse=True)
161
+ boundary_chars = set(
162
+ "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-:"
163
+ )
164
+
165
+ for entry in sorted(os.listdir(agents_dir)):
166
+ if not entry.endswith(".md"):
167
+ continue
168
+ agent_path = os.path.join(agents_dir, entry)
169
+ if not os.path.isfile(agent_path):
170
+ continue
171
+
172
+ with open(agent_path, "r", encoding="utf-8") as f:
173
+ text = f.read()
174
+
175
+ file_changes = 0
176
+
177
+ for name in ordered_names:
178
+ pattern = re.compile(r"/" + re.escape(name))
179
+ matches = []
180
+ for m in pattern.finditer(text):
181
+ start, end = m.start(), m.end()
182
+ if start > 0 and text[start - 1] in boundary_chars:
183
+ continue # part of a longer token, e.g. "x/status"
184
+ if end < len(text) and text[end] in boundary_chars:
185
+ continue # e.g. "/doc" inside "/doc-sync"
186
+ matches.append((start, end))
187
+ for start, end in reversed(matches):
188
+ text = text[:start] + f"j:{name}" + text[end:]
189
+ file_changes += 1
190
+
191
+ if file_changes:
192
+ changes.append({
193
+ "type": "agent-prose",
194
+ "file": agent_path,
195
+ "count": file_changes,
196
+ })
197
+ if not dry_run:
198
+ with open(agent_path, "w", encoding="utf-8") as f:
199
+ f.write(text)
200
+ elif do_agents and not os.path.isdir(agents_dir):
201
+ errors.append(f"agents directory not found: {agents_dir}")
202
+
203
+ # ---- report ----
204
+ mode = "DRY RUN — no files written" if dry_run else "APPLIED"
205
+ print(f"apply-j-prefix.sh — {mode}")
206
+ print(f"Skills discovered: {len(skill_names)}")
207
+ print()
208
+
209
+ if not changes:
210
+ print("No changes.")
211
+ else:
212
+ for c in changes:
213
+ if c["type"] == "frontmatter":
214
+ print(f"[frontmatter] {c['file']}")
215
+ print(f" - {c['before']}")
216
+ print(f" + {c['after']}")
217
+ else:
218
+ print(f"[agent-prose] {c['file']} — {c['count']} occurrence(s) rewritten")
219
+ print()
220
+ print(f"Total: {len(changes)} change group(s)")
221
+
222
+ if errors:
223
+ print()
224
+ print("Errors / skipped:")
225
+ for e in errors:
226
+ print(f" ⚠ {e}")
227
+ sys.exit(1)
228
+
229
+ sys.exit(0)
230
+ PY
@@ -0,0 +1,103 @@
1
+ #!/usr/bin/env bash
2
+ # ---------------------------------------------------------------------------
3
+ # scripts/consume-context-digest.sh
4
+ #
5
+ # Read-and-delete helper for the resolved_context digest handoff convention
6
+ # (E49_S01). The receiving subagent (the one whose sender object carries a
7
+ # resolved_context / resolved_context_path pointing at a file under
8
+ # project/queue/context/) calls this once, as part of reading its own sender
9
+ # object, to fetch the digest content and immediately mark the file consumed
10
+ # — mirroring project/queue/handoffs/'s single-use lifecycle rather than
11
+ # leaving cleanup to a convention every receiver has to remember to follow.
12
+ #
13
+ # Atomic claim: before reading, the target file is renamed to a
14
+ # `.claimed.$$` sibling in the same directory (same TOCTOU-safe pattern
15
+ # hooks/on_session_end.sh section 4 already uses when routing
16
+ # project/queue/handoffs/ files). `mv` between two paths on the same
17
+ # filesystem is a rename(2) syscall — the kernel guarantees exactly one
18
+ # concurrent claimer wins. If the rename fails, another process already
19
+ # consumed (or is consuming) this file; this invocation exits 3 without
20
+ # printing anything, rather than risking a double-read of a file that's
21
+ # about to disappear out from under it.
22
+ #
23
+ # This script does NOT sweep for abandoned digests nobody ever consumes —
24
+ # that backstop is scripts/sweep-stale-context-digests.sh, invoked from
25
+ # hooks/on_session_end.sh.
26
+ #
27
+ # Usage:
28
+ # consume-context-digest.sh <path-to-digest-file>
29
+ # consume-context-digest.sh --raw <path-to-digest-file>
30
+ #
31
+ # Options:
32
+ # --raw print only the digest field's text (not the full JSON envelope)
33
+ # -h, --help
34
+ #
35
+ # Output (stdout):
36
+ # the digest file's full JSON envelope (default), or just its `digest`
37
+ # field's text with --raw
38
+ #
39
+ # Exit codes:
40
+ # 0 digest read and file deleted
41
+ # 1 usage error
42
+ # 2 target file does not exist (already consumed, or never written)
43
+ # 3 target file was claimed by a concurrent consumer first
44
+ # 4 target file is not valid JSON / missing expected fields
45
+ # ---------------------------------------------------------------------------
46
+
47
+ set -euo pipefail
48
+
49
+ SELF="$(basename "$0")"
50
+
51
+ die() {
52
+ local code="$1"; shift
53
+ printf '%s: error: %s\n' "$SELF" "$*" >&2
54
+ exit "$code"
55
+ }
56
+
57
+ usage() {
58
+ sed -n '/^# Usage:/,/^# Exit codes:/p' "$0" | sed 's/^# \{0,1\}//' >&2
59
+ exit 1
60
+ }
61
+
62
+ command -v jq >/dev/null 2>&1 || die 1 "jq is required but not found on PATH"
63
+
64
+ RAW=0
65
+ TARGET=""
66
+
67
+ while [ "$#" -gt 0 ]; do
68
+ case "$1" in
69
+ --raw)
70
+ RAW=1; shift ;;
71
+ -h|--help)
72
+ usage ;;
73
+ -*)
74
+ die 1 "unknown option: $1" ;;
75
+ *)
76
+ [ -z "$TARGET" ] || die 1 "unexpected extra argument: $1"
77
+ TARGET="$1"; shift ;;
78
+ esac
79
+ done
80
+
81
+ [ -n "$TARGET" ] || usage
82
+ [ -f "$TARGET" ] || die 2 "digest file not found: $TARGET (already consumed, or never written)"
83
+
84
+ CLAIMED_FILE="${TARGET}.claimed.$$"
85
+ if ! mv "$TARGET" "$CLAIMED_FILE" 2>/dev/null; then
86
+ die 3 "digest file '$TARGET' was claimed by a concurrent consumer — skipping"
87
+ fi
88
+
89
+ # Always attempt to remove the claimed temp file, whether jq succeeds or not,
90
+ # so a malformed digest can't leave a permanent orphan behind.
91
+ cleanup() {
92
+ rm -f "$CLAIMED_FILE" 2>/dev/null || true
93
+ }
94
+ trap cleanup EXIT
95
+
96
+ if [ "$RAW" -eq 1 ]; then
97
+ DIGEST_TEXT=$(jq -r '.digest // empty' "$CLAIMED_FILE" 2>/dev/null) || die 4 "'$TARGET' is not valid digest JSON"
98
+ [ -n "$DIGEST_TEXT" ] || die 4 "'$TARGET' has no 'digest' field"
99
+ printf '%s\n' "$DIGEST_TEXT"
100
+ else
101
+ jq -e '.' "$CLAIMED_FILE" >/dev/null 2>&1 || die 4 "'$TARGET' is not valid JSON"
102
+ cat "$CLAIMED_FILE"
103
+ fi
@@ -33,6 +33,7 @@ import { fileURLToPath } from 'node:url';
33
33
 
34
34
  import { mirror } from '../lib/mirror.js';
35
35
  import { generateCopilotInstructions } from '../lib/generate-copilot-instructions.js';
36
+ import { generateSkillAllowList } from '../lib/generate-skill-allow-list.js';
36
37
 
37
38
  // ESM equivalent of __dirname
38
39
  const __filename = fileURLToPath(import.meta.url);
@@ -154,6 +155,30 @@ function main() {
154
155
  totalSkipped += result.skipped.length;
155
156
  }
156
157
 
158
+ // Regenerate the canonical skill allow-list (E50_S02) from the freshly mirrored
159
+ // `.agents/skills` BEFORE bootstrapping .github/copilot-instructions.md below — so a consumer
160
+ // install ships a correct, current allow-list from the very first `npm install`, not
161
+ // only after a later `/self-sync`. The output is written into this installed package's own
162
+ // `lib/` (not the consumer root — `lib/` is never duplicated into the consumer per this file's
163
+ // header comment), matching generateSkillAllowList's own default output location and the path
164
+ // the MCP router / native routing enforcement (E50_S02_T03/T04) read from at runtime.
165
+ //
166
+ // Ordering matters (E50_S02_T04): generateCopilotInstructions() below now renders an
167
+ // {{ALLOWED_SKILL_IDS}} block by reading this same lib/skill-allow-list.json path. Running
168
+ // this regeneration first means that render reflects the freshly-mirrored `.agents/skills`
169
+ // scan from this very postinstall run, not the stale artifact committed at publish time.
170
+ // Wrapped in a try/catch-and-warn pattern so a generation failure degrades gracefully
171
+ // (console warning) rather than failing the whole postinstall.
172
+ try {
173
+ const result = generateSkillAllowList(
174
+ path.join(consumerRoot, '.agents', 'skills'),
175
+ path.join(packageRoot, 'lib', 'skill-allow-list.json')
176
+ );
177
+ console.log(` ✓ lib/skill-allow-list.json regenerated (${result.skill_count} skills)`);
178
+ } catch (e) {
179
+ console.log(` ⚠ Could not regenerate lib/skill-allow-list.json — ${e.message}`);
180
+ }
181
+
157
182
  // Bootstrap .github/copilot-instructions.md unconditionally, right after the mirror step —
158
183
  // no interactive prompts, since postinstall runs unattended during `npm install`. Without
159
184
  // this, a consumer whose very first action is a Copilot slash command (before ever running
@@ -0,0 +1,132 @@
1
+ #!/usr/bin/env bash
2
+ # ---------------------------------------------------------------------------
3
+ # scripts/sweep-stale-context-digests.sh
4
+ #
5
+ # Staleness-cleanup backstop for project/queue/context/ digest files
6
+ # (E49_S01_T02). The PRIMARY cleanup path is scripts/consume-context-digest.sh,
7
+ # called by the receiving subagent once it has read its digest. This script
8
+ # exists for the cases that leaves uncovered:
9
+ # - a digest whose intended receiving session never starts (e.g. the
10
+ # dispatch it was written for was abandoned or superseded)
11
+ # - a receiver that reads the raw file directly instead of going through
12
+ # consume-context-digest.sh and forgets to delete it
13
+ #
14
+ # Invoked from hooks/on_session_end.sh on every session end (any agent), so
15
+ # it runs far more often than any single digest's realistic lifetime — a
16
+ # digest is meant to be consumed by the very next session dispatched for its
17
+ # task, not to sit for a full day. Age-based (mtime), not consumption-based:
18
+ # unlike project/queue/handoffs/ (routed and deleted deterministically, same
19
+ # hook invocation, by on_session_end.sh section 4), a digest's consumer is a
20
+ # LATER session that hasn't necessarily started yet when an unrelated
21
+ # session's SessionEnd hook fires — so this sweep must not delete a digest
22
+ # just because some other session ended in the meantime. The default
23
+ # threshold (24h) is deliberately generous for that reason.
24
+ #
25
+ # Usage:
26
+ # sweep-stale-context-digests.sh [--context-dir <dir>] [--max-age-seconds <n>]
27
+ #
28
+ # Options:
29
+ # --context-dir <dir> override project/queue/context (default:
30
+ # resolved via JENGA_PROJECT_DIR / git toplevel)
31
+ # --max-age-seconds <n> override the default 86400s (24h) threshold
32
+ # (env: CONTEXT_DIGEST_STALE_SECONDS)
33
+ # -h, --help
34
+ #
35
+ # Output (stdout): one line per file removed: "removed: <path> (age <n>s)"
36
+ #
37
+ # Exit codes:
38
+ # 0 ran successfully (including the common case of nothing to remove)
39
+ # 1 usage error
40
+ # ---------------------------------------------------------------------------
41
+
42
+ set -u
43
+
44
+ SELF="$(basename "$0")"
45
+
46
+ die() {
47
+ local code="$1"; shift
48
+ printf '%s: error: %s\n' "$SELF" "$*" >&2
49
+ exit "$code"
50
+ }
51
+
52
+ usage() {
53
+ sed -n '/^# Usage:/,/^# Exit codes:/p' "$0" | sed 's/^# \{0,1\}//' >&2
54
+ exit 1
55
+ }
56
+
57
+ resolve_project_dir() {
58
+ if [ -n "${JENGA_PROJECT_DIR:-}" ]; then
59
+ printf '%s\n' "$JENGA_PROJECT_DIR"
60
+ return 0
61
+ fi
62
+ if [ -n "${CLAUDE_PROJECT_DIR:-}" ]; then
63
+ printf '%s\n' "$CLAUDE_PROJECT_DIR"
64
+ return 0
65
+ fi
66
+ git rev-parse --show-toplevel 2>/dev/null || pwd
67
+ }
68
+
69
+ CONTEXT_DIR=""
70
+ MAX_AGE_SECONDS="${CONTEXT_DIGEST_STALE_SECONDS:-86400}"
71
+
72
+ while [ "$#" -gt 0 ]; do
73
+ case "$1" in
74
+ --context-dir)
75
+ [ "$#" -ge 2 ] || die 1 "--context-dir requires a value"
76
+ CONTEXT_DIR="$2"; shift 2 ;;
77
+ --context-dir=*)
78
+ CONTEXT_DIR="${1#--context-dir=}"; shift ;;
79
+ --max-age-seconds)
80
+ [ "$#" -ge 2 ] || die 1 "--max-age-seconds requires a value"
81
+ MAX_AGE_SECONDS="$2"; shift 2 ;;
82
+ --max-age-seconds=*)
83
+ MAX_AGE_SECONDS="${1#--max-age-seconds=}"; shift ;;
84
+ -h|--help)
85
+ usage ;;
86
+ *)
87
+ die 1 "unknown argument: $1" ;;
88
+ esac
89
+ done
90
+
91
+ case "$MAX_AGE_SECONDS" in
92
+ ''|*[!0-9]*) die 1 "--max-age-seconds must be a non-negative integer, got '$MAX_AGE_SECONDS'" ;;
93
+ esac
94
+
95
+ if [ -z "$CONTEXT_DIR" ]; then
96
+ PROJECT_DIR="$(resolve_project_dir)"
97
+ CONTEXT_DIR="$PROJECT_DIR/project/queue/context"
98
+ fi
99
+
100
+ [ -d "$CONTEXT_DIR" ] || exit 0
101
+
102
+ # Portable mtime lookup: GNU stat (Linux) uses -c, BSD stat (macOS) uses -f —
103
+ # same fallback pattern as scripts/with-lock.sh's lock_mtime_epoch().
104
+ file_mtime_epoch() {
105
+ stat -c %Y "$1" 2>/dev/null || stat -f %m "$1" 2>/dev/null
106
+ }
107
+
108
+ now_epoch() {
109
+ date +%s
110
+ }
111
+
112
+ NOW="$(now_epoch)"
113
+
114
+ for FILE in "$CONTEXT_DIR"/*.json; do
115
+ [ -e "$FILE" ] || continue
116
+ # Never sweep a file mid-claim by consume-context-digest.sh — that script
117
+ # renames its target to a .claimed.$$ sibling before reading it, and this
118
+ # glob (*.json) doesn't match that suffix, so in-flight claims are already
119
+ # excluded structurally. This comment documents that rather than adding a
120
+ # redundant runtime check.
121
+
122
+ MTIME="$(file_mtime_epoch "$FILE")" || continue
123
+ AGE=$(( NOW - MTIME ))
124
+
125
+ if [ "$AGE" -ge "$MAX_AGE_SECONDS" ]; then
126
+ if rm -f "$FILE" 2>/dev/null; then
127
+ echo "removed: $FILE (age ${AGE}s)"
128
+ fi
129
+ fi
130
+ done
131
+
132
+ exit 0
@@ -25,6 +25,11 @@ STATUS_VALUES = {
25
25
  "Blocked",
26
26
  "Backlog",
27
27
  "Done",
28
+ "Merged",
29
+ "Publicized",
30
+ "Privatized",
31
+ "Deployed to Stage",
32
+ "Deployed to Prod",
28
33
  }
29
34
 
30
35
  # E39 tiered item-level caution/escalation. crucial_level is OPTIONAL — absence means no