@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.
- package/README.md +17 -7
- package/bin/cli.js +197 -119
- package/obj/template/.claude/commands/grant-push.md +19 -0
- package/obj/template/.claude/commands/init-project.md +26 -4
- package/obj/template/.claude/hooks/consent_gate_grant.mjs +107 -0
- package/obj/template/.claude/hooks/git_commit_guard.mjs +224 -0
- package/obj/template/.claude/hooks/harness_continuation.sh +101 -34
- package/obj/template/.claude/hooks/lib/common.mjs +283 -0
- package/obj/template/.claude/hooks/lib/common.sh +1 -1
- package/obj/template/.claude/hooks/memory_session_start.sh +20 -6
- package/obj/template/.claude/hooks/memory_stop.sh +161 -2
- package/obj/template/.claude/hooks/spec_approval_guard.sh +1 -1
- package/obj/template/.claude/hooks/swarm_approval_guard.sh +1 -1
- package/obj/template/.claude/hooks/tests/fixtures/ac008_byte_equal_reference.txt +7 -7
- package/obj/template/.claude/hooks/tests/fixtures/memory_stop_landmark_baseline.txt +21 -0
- package/obj/template/.claude/hooks/tests/fixtures/regenerate-ac008.sh +47 -0
- package/obj/template/.claude/hooks/tests/memory_session_start_test.sh +7 -3
- package/obj/template/.claude/hooks/tests/memory_stop_intent_test.sh +329 -0
- package/obj/template/.claude/hooks/tests/regenerate_ac008_test.sh +99 -0
- package/obj/template/.claude/memory/README.md +8 -3
- package/obj/template/.claude/memory/backlog.md +12 -0
- package/obj/template/.claude/project.json +6 -1
- package/obj/template/.claude/settings.json +3 -4
- package/obj/template/.claude/skills/audit-baseline/audit.sh +28 -16
- package/obj/template/.claude/skills/audit-baseline/tests/fixtures/_pending_opener_only.md +3 -0
- package/obj/template/.claude/skills/audit-baseline/tests/fixtures/preamble_full_empty_body.md +4 -0
- package/obj/template/.claude/skills/audit-baseline/tests/fixtures/preamble_full_with_entries.md +9 -0
- package/obj/template/.claude/skills/audit-baseline/tests/fixtures/preamble_no_opener.md +3 -0
- package/obj/template/.claude/skills/audit-baseline/tests/fixtures/preamble_opener_only.md +3 -0
- package/obj/template/.claude/skills/audit-baseline/tests/preamble_check_test.sh +147 -0
- package/obj/template/.claude/skills/changelog/SKILL.md +69 -0
- package/obj/template/.claude/skills/changelog/changelog.mjs +163 -0
- package/obj/template/.claude/skills/changelog/classifier.mjs +49 -0
- package/obj/template/.claude/skills/changelog/state-writer.mjs +19 -0
- package/obj/template/.claude/skills/changelog/tests/consent-expired_test.sh +126 -0
- package/obj/template/.claude/skills/changelog/tests/golden-path_test.sh +191 -0
- package/obj/template/.claude/skills/changelog/tests/idempotent-reentry_test.sh +121 -0
- package/obj/template/.claude/skills/changelog/tests/keepachangelog-unreleased-preserved_test.mjs +149 -0
- package/obj/template/.claude/skills/changelog/tests/non-git-shortcircuit_test.sh +98 -0
- package/obj/template/.claude/skills/changelog/tests/preview-only_test.sh +96 -0
- package/obj/template/.claude/skills/changelog/tests/run.sh +28 -0
- package/obj/template/.claude/skills/changelog/unreleased-writer.mjs +155 -0
- package/obj/template/.claude/skills/changelog/version-preview.mjs +124 -0
- package/obj/template/.claude/skills/chore/SKILL.md +5 -3
- package/obj/template/.claude/skills/commit/SKILL.md +5 -4
- package/obj/template/.claude/skills/copywriting/LICENSE +21 -0
- package/obj/template/.claude/skills/copywriting/NOTICE +23 -0
- package/obj/template/.claude/skills/copywriting/SKILL.md +1 -1
- package/obj/template/.claude/skills/design-ui/SKILL.md +23 -5
- package/obj/template/.claude/skills/design-ui/references/design-vs-development.md +26 -5
- package/obj/template/.claude/skills/design-ui/references/orchestration.md +1 -0
- package/obj/template/.claude/skills/design-ui/references/state-machine.md +5 -3
- package/obj/template/.claude/skills/documentation/LICENSE +202 -0
- package/obj/template/.claude/skills/documentation/NOTICE +22 -0
- package/obj/template/.claude/skills/harness/SKILL.md +5 -1
- package/obj/template/.claude/skills/humanizer/LICENSE +21 -0
- package/obj/template/.claude/skills/humanizer/NOTICE +21 -0
- package/obj/template/.claude/skills/impeccable/LICENSE +202 -0
- package/obj/template/.claude/skills/impeccable/NOTICE +24 -0
- package/obj/template/.claude/skills/memory-flush/SKILL.md +20 -4
- package/obj/template/.claude/skills/memory-flush/sweep.py +74 -6
- package/obj/template/.claude/skills/memory-flush/tests/run.sh +300 -1
- package/obj/template/.claude/skills/tdd/SKILL.md +2 -1
- package/obj/template/.claude/skills/tdd/drift_check.py +180 -0
- package/obj/template/.claude/skills/tdd/tests/drift_check_test.sh +190 -0
- package/obj/template/.claude/skills/tdd/tests/run.sh +21 -0
- package/obj/template/.claude/skills/technical-tutorials/LICENSE +21 -0
- package/obj/template/.claude/skills/technical-tutorials/NOTICE +23 -0
- package/obj/template/.claude/skills/technical-tutorials/SKILL.md +1 -1
- package/obj/template/.claude/skills/triage/SKILL.md +11 -5
- package/obj/template/CLAUDE.md +36 -25
- package/obj/template/docs/init/seed.md +39 -24
- package/obj/template/manifest.json +73 -33
- package/package.json +5 -2
- package/src/CLAUDE.template.md +36 -25
- package/src/cli/merge.js +15 -10
- package/src/cli/tui/doctor.js +56 -0
- package/src/cli/tui/install.js +79 -0
- package/src/cli/tui/meta.js +30 -0
- package/src/cli/tui/tokens.js +38 -0
- package/src/cli/tui/upgrade.js +100 -0
- package/src/memory/backlog.template.md +12 -0
- package/src/project.template.json +6 -1
- package/src/seed.template.md +39 -24
- package/src/settings.template.json +3 -4
- package/obj/template/.claude/hooks/consent_gate_grant.sh +0 -89
- 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
|
-
#
|
|
10
|
-
#
|
|
11
|
-
#
|
|
12
|
-
#
|
|
13
|
-
#
|
|
14
|
-
#
|
|
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
|
-
#
|
|
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
|
-
#
|
|
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
|
-
#
|
|
46
|
+
# harness_state existence — both 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:
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
87
|
-
|
|
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
|
-
#
|
|
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
|
-
|
|
101
|
-
if
|
|
102
|
-
|
|
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
|
-
#
|
|
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 (
|
|
185
|
+
_log('INFO', f'emit: decision=block ({emit_log_detail})')
|
|
119
186
|
PY
|
|
120
187
|
|
|
121
188
|
exit 0
|