@friedbotstudio/create-baseline 0.2.1 → 0.4.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 (87) hide show
  1. package/README.md +17 -7
  2. package/bin/cli.js +197 -119
  3. package/obj/template/.claude/commands/grant-push.md +19 -0
  4. package/obj/template/.claude/commands/init-project.md +26 -4
  5. package/obj/template/.claude/hooks/consent_gate_grant.mjs +107 -0
  6. package/obj/template/.claude/hooks/git_commit_guard.mjs +224 -0
  7. package/obj/template/.claude/hooks/harness_continuation.sh +101 -34
  8. package/obj/template/.claude/hooks/lib/common.mjs +283 -0
  9. package/obj/template/.claude/hooks/lib/common.sh +1 -1
  10. package/obj/template/.claude/hooks/memory_session_start.sh +20 -6
  11. package/obj/template/.claude/hooks/memory_stop.sh +161 -2
  12. package/obj/template/.claude/hooks/spec_approval_guard.sh +1 -1
  13. package/obj/template/.claude/hooks/swarm_approval_guard.sh +1 -1
  14. package/obj/template/.claude/hooks/tests/fixtures/ac008_byte_equal_reference.txt +7 -7
  15. package/obj/template/.claude/hooks/tests/fixtures/memory_stop_landmark_baseline.txt +21 -0
  16. package/obj/template/.claude/hooks/tests/fixtures/regenerate-ac008.sh +47 -0
  17. package/obj/template/.claude/hooks/tests/memory_session_start_test.sh +7 -3
  18. package/obj/template/.claude/hooks/tests/memory_stop_intent_test.sh +329 -0
  19. package/obj/template/.claude/hooks/tests/regenerate_ac008_test.sh +99 -0
  20. package/obj/template/.claude/memory/README.md +8 -3
  21. package/obj/template/.claude/memory/backlog.md +12 -0
  22. package/obj/template/.claude/project.json +6 -1
  23. package/obj/template/.claude/settings.json +3 -4
  24. package/obj/template/.claude/skills/audit-baseline/audit.sh +28 -16
  25. package/obj/template/.claude/skills/audit-baseline/tests/fixtures/_pending_opener_only.md +3 -0
  26. package/obj/template/.claude/skills/audit-baseline/tests/fixtures/preamble_full_empty_body.md +4 -0
  27. package/obj/template/.claude/skills/audit-baseline/tests/fixtures/preamble_full_with_entries.md +9 -0
  28. package/obj/template/.claude/skills/audit-baseline/tests/fixtures/preamble_no_opener.md +3 -0
  29. package/obj/template/.claude/skills/audit-baseline/tests/fixtures/preamble_opener_only.md +3 -0
  30. package/obj/template/.claude/skills/audit-baseline/tests/preamble_check_test.sh +147 -0
  31. package/obj/template/.claude/skills/changelog/SKILL.md +69 -0
  32. package/obj/template/.claude/skills/changelog/changelog.mjs +163 -0
  33. package/obj/template/.claude/skills/changelog/classifier.mjs +49 -0
  34. package/obj/template/.claude/skills/changelog/state-writer.mjs +19 -0
  35. package/obj/template/.claude/skills/changelog/tests/consent-expired_test.sh +126 -0
  36. package/obj/template/.claude/skills/changelog/tests/golden-path_test.sh +191 -0
  37. package/obj/template/.claude/skills/changelog/tests/idempotent-reentry_test.sh +121 -0
  38. package/obj/template/.claude/skills/changelog/tests/keepachangelog-unreleased-preserved_test.mjs +149 -0
  39. package/obj/template/.claude/skills/changelog/tests/non-git-shortcircuit_test.sh +98 -0
  40. package/obj/template/.claude/skills/changelog/tests/preview-only_test.sh +96 -0
  41. package/obj/template/.claude/skills/changelog/tests/run.sh +28 -0
  42. package/obj/template/.claude/skills/changelog/unreleased-writer.mjs +155 -0
  43. package/obj/template/.claude/skills/changelog/version-preview.mjs +124 -0
  44. package/obj/template/.claude/skills/chore/SKILL.md +5 -3
  45. package/obj/template/.claude/skills/commit/SKILL.md +5 -4
  46. package/obj/template/.claude/skills/copywriting/LICENSE +21 -0
  47. package/obj/template/.claude/skills/copywriting/NOTICE +23 -0
  48. package/obj/template/.claude/skills/copywriting/SKILL.md +1 -1
  49. package/obj/template/.claude/skills/design-ui/SKILL.md +23 -5
  50. package/obj/template/.claude/skills/design-ui/references/design-vs-development.md +26 -5
  51. package/obj/template/.claude/skills/design-ui/references/orchestration.md +1 -0
  52. package/obj/template/.claude/skills/design-ui/references/state-machine.md +5 -3
  53. package/obj/template/.claude/skills/documentation/LICENSE +202 -0
  54. package/obj/template/.claude/skills/documentation/NOTICE +22 -0
  55. package/obj/template/.claude/skills/harness/SKILL.md +5 -1
  56. package/obj/template/.claude/skills/humanizer/LICENSE +21 -0
  57. package/obj/template/.claude/skills/humanizer/NOTICE +21 -0
  58. package/obj/template/.claude/skills/impeccable/LICENSE +202 -0
  59. package/obj/template/.claude/skills/impeccable/NOTICE +24 -0
  60. package/obj/template/.claude/skills/memory-flush/SKILL.md +20 -4
  61. package/obj/template/.claude/skills/memory-flush/sweep.py +74 -6
  62. package/obj/template/.claude/skills/memory-flush/tests/run.sh +300 -1
  63. package/obj/template/.claude/skills/tdd/SKILL.md +2 -1
  64. package/obj/template/.claude/skills/tdd/drift_check.py +180 -0
  65. package/obj/template/.claude/skills/tdd/tests/drift_check_test.sh +190 -0
  66. package/obj/template/.claude/skills/tdd/tests/run.sh +21 -0
  67. package/obj/template/.claude/skills/technical-tutorials/LICENSE +21 -0
  68. package/obj/template/.claude/skills/technical-tutorials/NOTICE +23 -0
  69. package/obj/template/.claude/skills/technical-tutorials/SKILL.md +1 -1
  70. package/obj/template/.claude/skills/triage/SKILL.md +11 -5
  71. package/obj/template/CLAUDE.md +36 -25
  72. package/obj/template/docs/init/seed.md +39 -24
  73. package/obj/template/manifest.json +73 -33
  74. package/package.json +5 -2
  75. package/src/CLAUDE.template.md +36 -25
  76. package/src/cli/merge.js +15 -10
  77. package/src/cli/tui/doctor.js +56 -0
  78. package/src/cli/tui/install.js +79 -0
  79. package/src/cli/tui/meta.js +30 -0
  80. package/src/cli/tui/tokens.js +38 -0
  81. package/src/cli/tui/upgrade.js +100 -0
  82. package/src/memory/backlog.template.md +12 -0
  83. package/src/project.template.json +6 -1
  84. package/src/seed.template.md +39 -24
  85. package/src/settings.template.json +3 -4
  86. package/obj/template/.claude/hooks/consent_gate_grant.sh +0 -89
  87. package/obj/template/.claude/hooks/git_commit_guard.sh +0 -93
@@ -0,0 +1,224 @@
1
+ #!/usr/bin/env node
2
+ // Git Commit Guard — PreToolUse(Bash) and PreToolUse(Write|Edit|MultiEdit)
3
+ //
4
+ // JS port of git_commit_guard.sh, adding branch-aware consent policy:
5
+ //
6
+ // 1. Bash matcher — branch-aware:
7
+ // - `git push` is no longer in FORBIDDEN_RE (was an unconditional
8
+ // hard-block; now policy-driven).
9
+ // - `git commit` and `git push` both consult `git rev-parse
10
+ // --abbrev-ref HEAD`. Detached HEAD ("HEAD") → DENY explicitly.
11
+ // - On a branch matched by project.json → git.protected_branches
12
+ // (or when that key is null/absent → every branch protected),
13
+ // commits require fresh commit_consent (300s) and pushes require
14
+ // fresh push_consent (300s).
15
+ // - When git.branch_pattern is set and the current branch does NOT
16
+ // match the regex, commits are denied with the pattern surfaced.
17
+ // - On a non-protected branch, commits and pushes proceed without
18
+ // consent.
19
+ //
20
+ // 2. Write matcher — unchanged behavior plus an arm for push_consent:
21
+ // - Blocks Claude from writing the marker files (commit/push_consent_grant).
22
+ // - Gates writes to commit_consent on a fresh commit-consent marker.
23
+ // - Gates writes to push_consent on a fresh push-consent marker.
24
+
25
+ import { execFileSync } from 'node:child_process';
26
+ import { existsSync, readFileSync, unlinkSync } from 'node:fs';
27
+ import {
28
+ readPayload,
29
+ payloadGet,
30
+ projectGet,
31
+ emitBlock,
32
+ emitAllow,
33
+ logLine,
34
+ canonicalRel,
35
+ canonicalSlug,
36
+ validateConsentMarker,
37
+ blockMarkerSelfWrite,
38
+ matchAnyGlob,
39
+ CLAUDE_PROJECT_ROOT,
40
+ STATE_DIR,
41
+ CONSENT_MARKER_COMMIT,
42
+ CONSENT_MARKER_COMMIT_REL,
43
+ CONSENT_MARKER_PUSH,
44
+ CONSENT_MARKER_PUSH_REL,
45
+ } from './lib/common.mjs';
46
+
47
+ const HOOK = 'git_commit_guard';
48
+
49
+ // Hard-blocks that apply regardless of consent or branch.
50
+ // NOTE: `git push` was previously included; removed in the branch-aware
51
+ // policy. The remaining ops are still flat-out forbidden because they
52
+ // rewrite history, skip safety, or sweep paths.
53
+ const FORBIDDEN_RE = new RegExp(
54
+ '(' +
55
+ '\\bgit\\s+commit\\b[^|&;]*--amend' +
56
+ '|--no-verify' +
57
+ '|--no-gpg-sign' +
58
+ '|\\bgit\\s+reset\\s+--hard\\b' +
59
+ '|\\bgit\\s+clean\\s+-[a-zA-Z]*f\\b' +
60
+ '|\\bgit\\s+checkout\\s+--\\s' +
61
+ '|\\bgit\\s+branch\\s+-D\\b' +
62
+ '|\\bgit\\s+config\\b' +
63
+ '|\\bgit\\s+rebase\\s+-i\\b' +
64
+ '|\\bgit\\s+add\\s+-i\\b' +
65
+ '|\\bgit\\s+add\\s+(-A|\\.)(?![A-Za-z0-9_/.\\-])' +
66
+ ')'
67
+ );
68
+
69
+ function currentBranch() {
70
+ try {
71
+ const out = execFileSync('git', ['rev-parse', '--abbrev-ref', 'HEAD'], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], cwd: CLAUDE_PROJECT_ROOT });
72
+ return out.trim();
73
+ } catch {
74
+ return null;
75
+ }
76
+ }
77
+
78
+ function isInsideWorkTree() {
79
+ try {
80
+ execFileSync('git', ['rev-parse', '--is-inside-work-tree'], { stdio: 'ignore', cwd: CLAUDE_PROJECT_ROOT });
81
+ return true;
82
+ } catch {
83
+ return false;
84
+ }
85
+ }
86
+
87
+ // Returns: { protected: bool, patternViolation: string|null, detached: bool, branch: string|null }
88
+ function branchPolicy() {
89
+ const branch = currentBranch();
90
+ if (branch === null) return { protected: false, patternViolation: null, detached: false, branch: null, notGit: true };
91
+ if (branch === 'HEAD') return { protected: false, patternViolation: null, detached: true, branch, notGit: false };
92
+
93
+ // protected_branches: null/absent → every branch protected; else glob match.
94
+ const globs = projectGet('.git.protected_branches');
95
+ let isProtected;
96
+ if (globs == null) {
97
+ isProtected = true;
98
+ } else if (Array.isArray(globs)) {
99
+ isProtected = matchAnyGlob(branch, globs);
100
+ } else {
101
+ isProtected = true; // invalid type → fail-safe to protected
102
+ }
103
+
104
+ // branch_pattern: null/absent → no check; string → must match.
105
+ const pattern = projectGet('.git.branch_pattern');
106
+ let patternViolation = null;
107
+ if (typeof pattern === 'string' && pattern.length > 0) {
108
+ try {
109
+ if (!new RegExp(pattern).test(branch)) patternViolation = pattern;
110
+ } catch {
111
+ // invalid regex → treat as no pattern, warn via log
112
+ logLine(HOOK, `WARN invalid git.branch_pattern regex: ${pattern}`);
113
+ }
114
+ }
115
+
116
+ return { protected: isProtected, patternViolation, detached: false, branch, notGit: false };
117
+ }
118
+
119
+ function validateConsentToken(file, ttlKey, defaultTtl, gateLabel, cmdHint) {
120
+ let ttl = projectGet(ttlKey);
121
+ if (typeof ttl !== 'number' || !Number.isFinite(ttl)) ttl = defaultTtl;
122
+ if (!existsSync(file)) {
123
+ logLine(HOOK, `BLOCKED no consent file: ${file}`);
124
+ emitBlock(`${gateLabel}: no consent granted. The user must run \`${cmdHint}\` before a ${cmdHint.includes('push') ? 'push' : 'commit'} is allowed. Consent is valid for ${ttl}s.`);
125
+ }
126
+ let grantedAt;
127
+ try {
128
+ grantedAt = readFileSync(file, 'utf8').split(/\r?\n/)[0].trim();
129
+ } catch {
130
+ grantedAt = '';
131
+ }
132
+ if (!/^\d+$/.test(grantedAt)) {
133
+ logLine(HOOK, `BLOCKED malformed consent file: ${file}`);
134
+ emitBlock(`${gateLabel}: consent file is malformed. Ask the user to re-run \`${cmdHint}\`.`);
135
+ }
136
+ const now = Math.floor(Date.now() / 1000);
137
+ const age = now - parseInt(grantedAt, 10);
138
+ if (age > ttl) {
139
+ logLine(HOOK, `BLOCKED consent expired age=${age}s ttl=${ttl}s file=${file}`);
140
+ emitBlock(`${gateLabel}: consent expired (${age}s old, TTL ${ttl}s). Ask the user to re-run \`${cmdHint}\`.`);
141
+ }
142
+ logLine(HOOK, `ALLOWED age=${age}s file=${file}`);
143
+ }
144
+
145
+ function handleBash(cmd) {
146
+ if (!cmd || !/(^|\s)git(\s|$)/.test(cmd)) emitAllow();
147
+
148
+ // Hard-blocks first. Push is NOT in this set anymore.
149
+ if (FORBIDDEN_RE.test(cmd)) {
150
+ logLine(HOOK, `BLOCKED forbidden git op: ${cmd}`);
151
+ emitBlock('Git Commit Guard: forbidden git operation detected. seed.md forbids `git commit --amend`, `--no-verify`, `--no-gpg-sign`, `git reset --hard`, `git clean -f`, `git checkout -- `, `git branch -D`, `git config`, `git rebase -i`, `git add -i`, `git add -A|.` regardless of consent or branch. Ask the user to approve by stating the exact command.');
152
+ }
153
+
154
+ const isCommit = /\bgit\s+commit\b/.test(cmd);
155
+ const isPush = /\bgit\s+push\b/.test(cmd);
156
+ if (!isCommit && !isPush) emitAllow();
157
+
158
+ // Article VII applicability: gate operations require git.
159
+ if (!isInsideWorkTree()) {
160
+ logLine(HOOK, `ALLOWED not-a-git-repo cmd=${cmd}`);
161
+ emitAllow();
162
+ }
163
+
164
+ const policy = branchPolicy();
165
+ if (policy.detached) {
166
+ logLine(HOOK, `BLOCKED detached HEAD cmd=${cmd}`);
167
+ emitBlock(`Git Commit Guard: detached HEAD. Check out a branch first. Branch-aware policy needs a named branch to evaluate \`git.protected_branches\` and \`git.branch_pattern\`.`);
168
+ }
169
+
170
+ if (isCommit && policy.patternViolation) {
171
+ logLine(HOOK, `BLOCKED branch_pattern violation branch=${policy.branch} pattern=${policy.patternViolation}`);
172
+ emitBlock(`Git Commit Guard: branch '${policy.branch}' does not match \`git.branch_pattern\` (\`${policy.patternViolation}\`). Rename the branch to conform, or set \`git.branch_pattern\` to null in project.json to disable naming enforcement.`);
173
+ }
174
+
175
+ if (!policy.protected) {
176
+ logLine(HOOK, `ALLOWED unprotected-branch branch=${policy.branch} cmd=${cmd}`);
177
+ emitAllow();
178
+ }
179
+
180
+ // Protected — require the matching consent token.
181
+ if (isCommit) {
182
+ validateConsentToken(`${STATE_DIR}/commit_consent`, '.consent.commit_ttl_seconds', 300, 'Git Commit Guard', '/grant-commit');
183
+ } else {
184
+ validateConsentToken(`${STATE_DIR}/push_consent`, '.consent.push_ttl_seconds', 300, 'Git Commit Guard', '/grant-push');
185
+ }
186
+ emitAllow();
187
+ }
188
+
189
+ function handleWrite(payload) {
190
+ const filePath = payloadGet(payload, '.tool_input.file_path');
191
+ if (!filePath) emitAllow();
192
+ const rel = canonicalRel(filePath);
193
+ if (!rel) emitAllow();
194
+
195
+ // Block self-write of the commit / push markers (Claude can never forge them).
196
+ blockMarkerSelfWrite(rel, CONSENT_MARKER_COMMIT_REL, 'Git Commit Guard', '/grant-commit');
197
+ blockMarkerSelfWrite(rel, CONSENT_MARKER_PUSH_REL, 'Git Commit Guard', '/grant-push');
198
+
199
+ // Gate writes to the consent state files on a fresh marker.
200
+ if (rel === '.claude/state/commit_consent') {
201
+ validateConsentMarker(CONSENT_MARKER_COMMIT, 'Git Commit Guard', '/grant-commit');
202
+ } else if (rel === '.claude/state/push_consent') {
203
+ validateConsentMarker(CONSENT_MARKER_PUSH, 'Git Commit Guard', '/grant-push');
204
+ }
205
+ emitAllow();
206
+ }
207
+
208
+ async function main() {
209
+ const payload = await readPayload();
210
+ const tool = payloadGet(payload, '.tool_name');
211
+ if (tool === 'Bash') {
212
+ const cmd = payloadGet(payload, '.tool_input.command');
213
+ handleBash(cmd);
214
+ } else if (tool === 'Write' || tool === 'Edit' || tool === 'MultiEdit') {
215
+ handleWrite(payload);
216
+ } else {
217
+ emitAllow();
218
+ }
219
+ }
220
+
221
+ main().catch((err) => {
222
+ logLine(HOOK, `ERROR ${err && err.message ? err.message : String(err)}`);
223
+ emitAllow();
224
+ });
@@ -6,18 +6,25 @@
6
6
  # tick) and decides whether to re-fire harness on the same turn or stay
7
7
  # silent.
8
8
  #
9
- # Three-rung gate (plus sanity rail) ALL three must pass to emit a block:
10
- # 1. stop_hook_active flag absent on payload (avoids in-turn recursion).
11
- # 2. .claude/state/.harness_active exists (session-scoped in-the-loop marker;
12
- # the harness skill creates it on continue, deletes it on yielded/done;
13
- # memory_session_start.sh deletes it on session boundary).
14
- # 3. harness_state.state equals "continue".
9
+ # Gate has two disjunctive paths, both gated by rung 1:
10
+ # Path A (mid-loop continuation):
11
+ # 1. stop_hook_active flag absent on payload (avoids in-turn recursion).
12
+ # 2. .claude/state/.harness_active marker exists (session-scoped).
13
+ # 3. harness_state.state equals "continue".
14
+ # Path B (rung 4 — gate-resume after a consent slash command):
15
+ # 1. stop_hook_active flag absent.
16
+ # 4a. harness_state.state equals "yielded".
17
+ # 4b. .claude/state/workflow.json exists and parses.
18
+ # 4c. at least one of {commit_consent, push_consent,
19
+ # spec_approvals/<slug>.approval, swarm_approvals/<slug>.approval}
20
+ # exists with mtime newer than harness_state's mtime.
21
+ # If Path A or Path B passes, the sanity rail runs and a block decision
22
+ # is emitted. Otherwise: exit 0 silent.
15
23
  #
16
24
  # Sanity rail: if the marker's slug content disagrees with workflow.json.slug,
17
25
  # log one WARN line to harness_continuation.log; the decision is unchanged.
18
26
  #
19
- # If all three pass, emit {"decision":"block","reason":"..."} to stdout.
20
- # Otherwise: exit 0 silent. Internal failures are treated as silence.
27
+ # Internal failures are treated as silence.
21
28
 
22
29
  # shellcheck source=./lib/common.sh
23
30
  . "${BASH_SOURCE[0]%/*}/lib/common.sh"
@@ -32,17 +39,14 @@ case "$STOP_ACTIVE" in
32
39
  ;;
33
40
  esac
34
41
 
35
- # Rung 2: active marker presencesession-scoped "in the loop" signal.
42
+ # Marker path (presence check is now inside Python Path B can fire with
43
+ # the marker absent).
36
44
  MARKER="$STATE_DIR/.harness_active"
37
- if [ ! -f "$MARKER" ]; then
38
- log_line harness_continuation "silent: rung2 marker missing ($MARKER)"
39
- exit 0
40
- fi
41
45
 
42
- # Rung 3 (plus sanity rail + emit) delegate to python for JSON parsing.
46
+ # harness_state existenceboth paths need it readable.
43
47
  HARNESS_STATE="$STATE_DIR/harness_state"
44
48
  if [ ! -r "$HARNESS_STATE" ]; then
45
- log_line harness_continuation "silent: rung3a harness_state missing or unreadable"
49
+ log_line harness_continuation "silent: harness_state missing or unreadable"
46
50
  exit 0
47
51
  fi
48
52
 
@@ -74,21 +78,92 @@ def _warn(message):
74
78
  _log('WARN', message)
75
79
 
76
80
 
77
- # Rung 3: parse harness_state and check state field.
81
+ def _read_workflow_slug():
82
+ """Return slug string from workflow.json, or None if file missing/unparseable.
83
+
84
+ An empty-string return ('') means the file exists and parses but has no
85
+ slug field — workflow.json present but ungated by slug.
86
+ """
87
+ if not workflow_path or not os.path.exists(workflow_path):
88
+ return None
89
+ try:
90
+ with open(workflow_path) as f:
91
+ wf = json.load(f)
92
+ return wf.get('slug') or ''
93
+ except Exception:
94
+ return None
95
+
96
+
97
+ def _any_consent_newer_than(reference_mtime, workflow_slug):
98
+ """Rung 4c: is there a consent/approval token with mtime > reference_mtime?
99
+
100
+ Scans four canonical paths under $STATE_DIR. The two slug-gated paths
101
+ (spec_approvals, swarm_approvals) only check when workflow_slug is
102
+ non-empty.
103
+ """
104
+ state_dir = os.path.dirname(state_path)
105
+ candidates = [
106
+ os.path.join(state_dir, 'commit_consent'),
107
+ os.path.join(state_dir, 'push_consent'),
108
+ ]
109
+ if workflow_slug:
110
+ candidates.append(
111
+ os.path.join(state_dir, 'spec_approvals', f'{workflow_slug}.approval')
112
+ )
113
+ candidates.append(
114
+ os.path.join(state_dir, 'swarm_approvals', f'{workflow_slug}.approval')
115
+ )
116
+ for path in candidates:
117
+ try:
118
+ if os.path.getmtime(path) > reference_mtime:
119
+ return True
120
+ except OSError:
121
+ continue
122
+ return False
123
+
124
+
125
+ # Parse harness_state and capture its mtime (rung 4 uses the mtime for the
126
+ # fresh-consent comparison).
78
127
  try:
128
+ state_mtime = os.path.getmtime(state_path)
79
129
  with open(state_path) as f:
80
130
  data = json.load(f)
81
131
  except Exception as e:
82
- _log('INFO', f'silent: rung3b harness_state unparseable ({e!s})')
132
+ _log('INFO', f'silent: harness_state unparseable ({e!s})')
83
133
  sys.exit(0)
84
134
 
85
135
  state_value = data.get('state')
86
- if state_value != 'continue':
87
- _log('INFO', f'silent: rung3c state={state_value!r} (expected "continue")')
136
+
137
+ # Read workflow.json's slug ONCE; both Path B's rung-4 check and the sanity
138
+ # rail consume it. None = missing/unparseable; '' = present but no slug.
139
+ workflow_slug = _read_workflow_slug()
140
+
141
+ # Branch on state. Path A handles 'continue'; Path B (rung 4) handles
142
+ # 'yielded'. Anything else is silent.
143
+ emit_log_detail = ''
144
+
145
+ if state_value == 'continue':
146
+ # Path A: marker must be present (rung 2).
147
+ if not marker_path or not os.path.exists(marker_path):
148
+ _log('INFO', 'silent: rung2 marker missing for Path A (state=continue)')
149
+ sys.exit(0)
150
+ emit_log_detail = 'Path A (state=continue + marker present)'
151
+ elif state_value == 'yielded':
152
+ # Path B (rung 4): workflow.json must exist; a consent token must be
153
+ # newer than harness_state mtime.
154
+ if workflow_slug is None:
155
+ _log('INFO', 'silent: rung4 workflow.json missing or unparseable')
156
+ sys.exit(0)
157
+ if not _any_consent_newer_than(state_mtime, workflow_slug):
158
+ _log('INFO', 'silent: rung4 no consent token newer than harness_state')
159
+ sys.exit(0)
160
+ emit_log_detail = 'Path B (rung 4, state=yielded + fresh consent)'
161
+ else:
162
+ _log('INFO', f'silent: state={state_value!r} (not "continue" or "yielded")')
88
163
  sys.exit(0)
89
164
 
90
- # Sanity rail: marker slug should match workflow.json slug.
91
- # Mismatch is a WARN log line; the decision is unchanged.
165
+ # Sanity rail: marker slug should match workflow.json slug. Mismatch is a
166
+ # WARN log line; the decision is unchanged.
92
167
  marker_slug = ''
93
168
  if marker_path and os.path.exists(marker_path):
94
169
  try:
@@ -97,25 +172,17 @@ if marker_path and os.path.exists(marker_path):
97
172
  except Exception:
98
173
  marker_slug = ''
99
174
 
100
- workflow_slug = ''
101
- if workflow_path and os.path.exists(workflow_path):
102
- try:
103
- with open(workflow_path) as f:
104
- wf = json.load(f)
105
- workflow_slug = wf.get('slug') or ''
106
- except Exception:
107
- workflow_slug = ''
108
-
109
- if marker_slug and workflow_slug and marker_slug != workflow_slug:
110
- _warn(f'slug mismatch: marker={marker_slug} workflow={workflow_slug}')
175
+ rail_workflow_slug = workflow_slug or ''
176
+ if marker_slug and rail_workflow_slug and marker_slug != rail_workflow_slug:
177
+ _warn(f'slug mismatch: marker={marker_slug} workflow={rail_workflow_slug}')
111
178
 
112
- # All rungs passed — emit the block decision.
179
+ # Gate passed — emit the block decision.
113
180
  decision = {
114
181
  'decision': 'block',
115
182
  'reason': 'Workflow continuing per harness_state. Invoke Skill(harness) to advance to the next phase.',
116
183
  }
117
184
  print(json.dumps(decision))
118
- _log('INFO', 'emit: decision=block (all rungs passed)')
185
+ _log('INFO', f'emit: decision=block ({emit_log_detail})')
119
186
  PY
120
187
 
121
188
  exit 0