create-byan-agent 2.16.1 → 2.17.1
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/CHANGELOG.md +78 -0
- package/README.md +24 -0
- package/install/lib/claude-native-setup.js +37 -0
- package/install/package.json +1 -1
- package/install/packages/platform-config/lib/validate.js +0 -14
- package/install/src/webui/api.js +6 -0
- package/install/src/webui/server.js +8 -1
- package/install/templates/.claude/CLAUDE.md +18 -0
- package/install/templates/.claude/hooks/lib/strict-config.json +46 -0
- package/install/templates/.claude/hooks/lib/strict-runtime.js +82 -0
- package/install/templates/.claude/hooks/strict-context-inject.js +86 -0
- package/install/templates/.claude/hooks/strict-scope-guard.js +101 -0
- package/install/templates/.claude/hooks/strict-stop-guard.js +100 -0
- package/install/templates/.claude/rules/strict-mode.md +166 -0
- package/install/templates/.claude/settings.json +12 -0
- package/install/templates/.claude/skills/byan-strict/SKILL.md +54 -0
- package/install/templates/.githooks/pre-commit +15 -0
- package/install/templates/_byan/_config/strict-mode.yaml +258 -0
- package/install/templates/_byan/mcp/byan-mcp-server/bin/byan-sync-rules.js +24 -0
- package/install/templates/_byan/mcp/byan-mcp-server/bin/strict-precommit-gate.js +21 -0
- package/install/templates/_byan/mcp/byan-mcp-server/lib/fd-state.js +2 -1
- package/install/templates/_byan/mcp/byan-mcp-server/lib/precommit-gate.js +120 -0
- package/install/templates/_byan/mcp/byan-mcp-server/lib/strict-activation.js +76 -0
- package/install/templates/_byan/mcp/byan-mcp-server/lib/strict-mode.js +391 -0
- package/install/templates/_byan/mcp/byan-mcp-server/lib/strict-sync.js +140 -0
- package/install/templates/_byan/mcp/byan-mcp-server/lib/sync-rules.js +261 -0
- package/install/templates/_byan/mcp/byan-mcp-server/server.js +207 -1
- package/package.json +6 -2
- package/src/byan-v2/data/strict-mantras.json +188 -0
- package/src/byan-v2/generation/mantra-validator.js +39 -4
|
@@ -70,7 +70,7 @@ function stampId(now = new Date(), slug) {
|
|
|
70
70
|
return `${s}-${slugify(slug)}`;
|
|
71
71
|
}
|
|
72
72
|
|
|
73
|
-
export function start({ featureName, projectRoot, now = new Date(), force = false } = {}) {
|
|
73
|
+
export function start({ featureName, projectRoot, now = new Date(), force = false, strict = false } = {}) {
|
|
74
74
|
const existing = readState(projectRoot);
|
|
75
75
|
if (existing && !['COMPLETED', 'ABORTED'].includes(existing.phase) && !force) {
|
|
76
76
|
throw new Error(
|
|
@@ -82,6 +82,7 @@ export function start({ featureName, projectRoot, now = new Date(), force = fals
|
|
|
82
82
|
fd_id: stampId(now, featureName),
|
|
83
83
|
feature_name: featureName || 'unnamed',
|
|
84
84
|
phase: 'DISCOVERY',
|
|
85
|
+
strict_mode: Boolean(strict),
|
|
85
86
|
started_at: now.toISOString(),
|
|
86
87
|
updated_at: now.toISOString(),
|
|
87
88
|
phase_history: [{ phase: 'DISCOVERY', entered_at: now.toISOString() }],
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import { getStatus, MIN_PASSES } from './strict-mode.js';
|
|
2
|
+
import { fetchSession, syncEnabled } from './strict-sync.js';
|
|
3
|
+
|
|
4
|
+
// BYAN Strict Mode pre-commit gate.
|
|
5
|
+
//
|
|
6
|
+
// The final, platform-agnostic net. Claude Code has in-session hooks; Codex
|
|
7
|
+
// and Copilot do not. This gate runs at commit time on every platform, so an
|
|
8
|
+
// agent that engaged strict mode but bailed on verification cannot land the
|
|
9
|
+
// commit.
|
|
10
|
+
//
|
|
11
|
+
// The byan_web API is the authority. At commit time the gate asks the API for
|
|
12
|
+
// the session record and judges that; the local .byan-strict/ mirror is the
|
|
13
|
+
// fallback only when the API is genuinely unreachable (so an offline machine
|
|
14
|
+
// is not hard-blocked, but online the server's word is final).
|
|
15
|
+
//
|
|
16
|
+
// Decision (same on either source) :
|
|
17
|
+
// - No strict session -> PASS (strict was not engaged).
|
|
18
|
+
// - Session aborted -> PASS (deliberate, audited exit).
|
|
19
|
+
// - Session engaged but not completed -> BLOCK.
|
|
20
|
+
// - Completed but < min passes
|
|
21
|
+
// or last verdict not "ok" -> BLOCK (completion was not earned).
|
|
22
|
+
// - Completed correctly -> PASS.
|
|
23
|
+
|
|
24
|
+
// Pure decision over a normalized status shape:
|
|
25
|
+
// { hasSession, active, scopeLocked, completed, passCount, minPasses, passes:[{verdict}], auditToken, sessionId }
|
|
26
|
+
export function decide(status) {
|
|
27
|
+
if (!status || !status.hasSession) {
|
|
28
|
+
return { pass: true, reason: 'no strict session — strict mode not engaged' };
|
|
29
|
+
}
|
|
30
|
+
if (status.active === false && !status.completed) {
|
|
31
|
+
return { pass: true, reason: 'strict session aborted (audited) — allowed' };
|
|
32
|
+
}
|
|
33
|
+
if (!status.scopeLocked) {
|
|
34
|
+
return { pass: true, reason: 'no scope locked — strict mode not engaged' };
|
|
35
|
+
}
|
|
36
|
+
if (!status.completed) {
|
|
37
|
+
return {
|
|
38
|
+
pass: false,
|
|
39
|
+
reason:
|
|
40
|
+
`Strict session ${status.sessionId} is engaged but not completed ` +
|
|
41
|
+
`(${status.passCount}/${status.minPasses} self-verify passes). ` +
|
|
42
|
+
`Run byan_strict_self_verify until satisfied, then byan_strict_complete. ` +
|
|
43
|
+
`To exit strict mode deliberately, call byan_strict_abort.`,
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
if (status.passCount < status.minPasses) {
|
|
47
|
+
return {
|
|
48
|
+
pass: false,
|
|
49
|
+
reason:
|
|
50
|
+
`Strict session completed with only ${status.passCount}/${status.minPasses} ` +
|
|
51
|
+
`self-verify passes. This should not happen — investigate the audit trail.`,
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
const passes = status.passes || [];
|
|
55
|
+
const last = passes[passes.length - 1];
|
|
56
|
+
if (!last || last.verdict !== 'ok') {
|
|
57
|
+
return {
|
|
58
|
+
pass: false,
|
|
59
|
+
reason:
|
|
60
|
+
`Strict session completed but the last self-verify verdict was ` +
|
|
61
|
+
`"${last ? last.verdict : 'none'}" (must be "ok").`,
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
return {
|
|
65
|
+
pass: true,
|
|
66
|
+
reason: `strict session completed (${status.source}): ${status.passCount} passes, audit token ${status.auditToken}`,
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function normalizeLocal(local) {
|
|
71
|
+
const noState =
|
|
72
|
+
local.active === false && !local.scope_locked && !local.completed;
|
|
73
|
+
return {
|
|
74
|
+
source: 'local',
|
|
75
|
+
hasSession: !noState,
|
|
76
|
+
active: local.active,
|
|
77
|
+
scopeLocked: Boolean(local.scope_locked),
|
|
78
|
+
completed: Boolean(local.completed),
|
|
79
|
+
passCount: local.pass_count || 0,
|
|
80
|
+
minPasses: local.min_passes || MIN_PASSES,
|
|
81
|
+
passes: local.passes || [],
|
|
82
|
+
auditToken: local.audit_token || null,
|
|
83
|
+
sessionId: local.strict_session_id || null,
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function normalizeApi(api) {
|
|
88
|
+
const passes = (api.passes || []).map((p) => ({ verdict: p.verdict }));
|
|
89
|
+
return {
|
|
90
|
+
source: 'api',
|
|
91
|
+
hasSession: true,
|
|
92
|
+
active: api.active !== false && !api.aborted && !api.completed,
|
|
93
|
+
scopeLocked: true,
|
|
94
|
+
completed: Boolean(api.completed),
|
|
95
|
+
passCount: passes.length,
|
|
96
|
+
minPasses: MIN_PASSES,
|
|
97
|
+
passes,
|
|
98
|
+
auditToken: api.audit_token || null,
|
|
99
|
+
sessionId: api.id || null,
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export async function evaluateGate({ projectRoot, fetchImpl } = {}) {
|
|
104
|
+
const local = getStatus({ projectRoot });
|
|
105
|
+
const normalizedLocal = normalizeLocal(local);
|
|
106
|
+
|
|
107
|
+
// Consult the authority when there is a session to check and a token is set.
|
|
108
|
+
if (normalizedLocal.sessionId && syncEnabled()) {
|
|
109
|
+
const remote = await fetchSession(
|
|
110
|
+
{ sessionId: normalizedLocal.sessionId },
|
|
111
|
+
fetchImpl ? { fetchImpl } : {}
|
|
112
|
+
);
|
|
113
|
+
if (remote.ok && remote.data) {
|
|
114
|
+
return decide(normalizeApi(remote.data));
|
|
115
|
+
}
|
|
116
|
+
// API unreachable / not found -> fall back to local mirror.
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
return decide(normalizedLocal);
|
|
120
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import yaml from 'js-yaml';
|
|
4
|
+
|
|
5
|
+
// Strict-mode activation detector.
|
|
6
|
+
//
|
|
7
|
+
// Reads the activation keywords from the single source of truth
|
|
8
|
+
// (_byan/_config/strict-mode.yaml) and reports whether a piece of text
|
|
9
|
+
// (a user request, a feature name) signals a production-grade deliverable
|
|
10
|
+
// that should be built under strict mode.
|
|
11
|
+
//
|
|
12
|
+
// This is the platform-agnostic counterpart to the strict-context-inject
|
|
13
|
+
// hook : Codex and Copilot have no in-session hook, so they call
|
|
14
|
+
// byan_strict_suggest to get the same signal.
|
|
15
|
+
|
|
16
|
+
const DEFAULT_CONFIG_REL = path.join('_byan', '_config', 'strict-mode.yaml');
|
|
17
|
+
const FALLBACK_KEYWORDS = [
|
|
18
|
+
'prod',
|
|
19
|
+
'production',
|
|
20
|
+
'client',
|
|
21
|
+
'contrat',
|
|
22
|
+
'template officiel',
|
|
23
|
+
'livrable',
|
|
24
|
+
'deliverable',
|
|
25
|
+
'release',
|
|
26
|
+
];
|
|
27
|
+
|
|
28
|
+
function resolveRoot(projectRoot) {
|
|
29
|
+
return projectRoot || process.env.CLAUDE_PROJECT_DIR || process.cwd();
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function loadKeywords({ projectRoot, configPath } = {}) {
|
|
33
|
+
const file = configPath || path.join(resolveRoot(projectRoot), DEFAULT_CONFIG_REL);
|
|
34
|
+
try {
|
|
35
|
+
if (fs.existsSync(file)) {
|
|
36
|
+
const cfg = yaml.load(fs.readFileSync(file, 'utf8'));
|
|
37
|
+
const kw = cfg && cfg.activation && cfg.activation.auto_keywords;
|
|
38
|
+
if (Array.isArray(kw) && kw.length > 0) return kw;
|
|
39
|
+
}
|
|
40
|
+
} catch {
|
|
41
|
+
// fall through to fallback list
|
|
42
|
+
}
|
|
43
|
+
return FALLBACK_KEYWORDS;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function matchKeyword(text, keyword) {
|
|
47
|
+
const kw = String(keyword).toLowerCase();
|
|
48
|
+
const lower = text.toLowerCase();
|
|
49
|
+
// Single alphabetic words match on word boundary to avoid false hits
|
|
50
|
+
// (e.g. "prod" should not match "reproduction").
|
|
51
|
+
if (/^[a-z]+$/.test(kw)) {
|
|
52
|
+
return new RegExp(`\\b${kw}\\b`).test(lower);
|
|
53
|
+
}
|
|
54
|
+
return lower.includes(kw);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function detectActivation({ text, projectRoot, configPath } = {}) {
|
|
58
|
+
if (!text || typeof text !== 'string') {
|
|
59
|
+
return { suggested: false, matched: [], message: '' };
|
|
60
|
+
}
|
|
61
|
+
const keywords = loadKeywords({ projectRoot, configPath });
|
|
62
|
+
const matched = keywords.filter((k) => matchKeyword(text, k));
|
|
63
|
+
|
|
64
|
+
if (matched.length === 0) {
|
|
65
|
+
return { suggested: false, matched: [], message: '' };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const message =
|
|
69
|
+
`The request mentions ${matched.map((m) => `"${m}"`).join(', ')}, ` +
|
|
70
|
+
`which signals a production-grade deliverable. Lock strict mode with ` +
|
|
71
|
+
`byan_strict_lock_scope (verbatim scope + testable acceptance criteria) ` +
|
|
72
|
+
`before building. Strict mode enforces self-verification and a 95% ` +
|
|
73
|
+
`confidence floor on hard claims.`;
|
|
74
|
+
|
|
75
|
+
return { suggested: true, matched, message };
|
|
76
|
+
}
|
|
@@ -0,0 +1,391 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import crypto from 'node:crypto';
|
|
4
|
+
|
|
5
|
+
const STRICT_DIR = '.byan-strict';
|
|
6
|
+
const STATE_FILE = 'state.json';
|
|
7
|
+
const AUDIT_FILE = 'audit.log';
|
|
8
|
+
const MIN_SELF_VERIFY_PASSES = 3;
|
|
9
|
+
|
|
10
|
+
function resolveRoot(projectRoot) {
|
|
11
|
+
return projectRoot || process.env.CLAUDE_PROJECT_DIR || process.cwd();
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function strictDir(projectRoot) {
|
|
15
|
+
return path.join(resolveRoot(projectRoot), STRICT_DIR);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function statePath(projectRoot) {
|
|
19
|
+
return path.join(strictDir(projectRoot), STATE_FILE);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function auditPath(projectRoot) {
|
|
23
|
+
return path.join(strictDir(projectRoot), AUDIT_FILE);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function ensureDir(filePath) {
|
|
27
|
+
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function readState(projectRoot) {
|
|
31
|
+
const p = statePath(projectRoot);
|
|
32
|
+
if (!fs.existsSync(p)) return null;
|
|
33
|
+
try {
|
|
34
|
+
return JSON.parse(fs.readFileSync(p, 'utf8'));
|
|
35
|
+
} catch {
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function writeState(state, projectRoot) {
|
|
41
|
+
const p = statePath(projectRoot);
|
|
42
|
+
ensureDir(p);
|
|
43
|
+
fs.writeFileSync(p, JSON.stringify(state, null, 2));
|
|
44
|
+
return p;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function appendAudit(entry, projectRoot) {
|
|
48
|
+
const p = auditPath(projectRoot);
|
|
49
|
+
ensureDir(p);
|
|
50
|
+
fs.appendFileSync(p, JSON.stringify(entry) + '\n');
|
|
51
|
+
return p;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function readAuditLog(projectRoot) {
|
|
55
|
+
const p = auditPath(projectRoot);
|
|
56
|
+
if (!fs.existsSync(p)) return [];
|
|
57
|
+
const raw = fs.readFileSync(p, 'utf8');
|
|
58
|
+
return raw
|
|
59
|
+
.split('\n')
|
|
60
|
+
.filter((line) => line.trim().length > 0)
|
|
61
|
+
.map((line) => {
|
|
62
|
+
try {
|
|
63
|
+
return JSON.parse(line);
|
|
64
|
+
} catch {
|
|
65
|
+
return null;
|
|
66
|
+
}
|
|
67
|
+
})
|
|
68
|
+
.filter(Boolean);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function hashScope(scopeText, acceptanceCriteria, allowedPaths) {
|
|
72
|
+
const payload = JSON.stringify({
|
|
73
|
+
scope: String(scopeText || '').trim(),
|
|
74
|
+
criteria: Array.isArray(acceptanceCriteria) ? acceptanceCriteria : [],
|
|
75
|
+
paths: Array.isArray(allowedPaths) ? allowedPaths : [],
|
|
76
|
+
});
|
|
77
|
+
return crypto.createHash('sha256').update(payload).digest('hex').slice(0, 16);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function stampId(now, slug) {
|
|
81
|
+
const pad = (n) => String(n).padStart(2, '0');
|
|
82
|
+
const s =
|
|
83
|
+
now.getFullYear().toString() +
|
|
84
|
+
pad(now.getMonth() + 1) +
|
|
85
|
+
pad(now.getDate()) +
|
|
86
|
+
'-' +
|
|
87
|
+
pad(now.getHours()) +
|
|
88
|
+
pad(now.getMinutes()) +
|
|
89
|
+
pad(now.getSeconds());
|
|
90
|
+
return `${s}-${slug || 'strict'}`;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function emptyState(now) {
|
|
94
|
+
return {
|
|
95
|
+
strict_session_id: stampId(now, 'session'),
|
|
96
|
+
active: true,
|
|
97
|
+
started_at: now.toISOString(),
|
|
98
|
+
updated_at: now.toISOString(),
|
|
99
|
+
scope_lock: null,
|
|
100
|
+
self_verify_passes: [],
|
|
101
|
+
completed: false,
|
|
102
|
+
completed_at: null,
|
|
103
|
+
audit_token: null,
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export function lockScope({
|
|
108
|
+
scopeText,
|
|
109
|
+
acceptanceCriteria = [],
|
|
110
|
+
allowedPaths = [],
|
|
111
|
+
projectRoot,
|
|
112
|
+
now = new Date(),
|
|
113
|
+
force = false,
|
|
114
|
+
} = {}) {
|
|
115
|
+
if (!scopeText || typeof scopeText !== 'string' || scopeText.trim().length < 10) {
|
|
116
|
+
throw new Error(
|
|
117
|
+
'scopeText must be a non-empty string of at least 10 chars describing the locked scope.'
|
|
118
|
+
);
|
|
119
|
+
}
|
|
120
|
+
if (!Array.isArray(acceptanceCriteria) || acceptanceCriteria.length === 0) {
|
|
121
|
+
throw new Error(
|
|
122
|
+
'acceptanceCriteria must be a non-empty array of strings (explicit deliverables).'
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const scopeHash = hashScope(scopeText, acceptanceCriteria, allowedPaths);
|
|
127
|
+
|
|
128
|
+
let state = readState(projectRoot);
|
|
129
|
+
if (state && state.active && state.scope_lock && !state.completed && !force) {
|
|
130
|
+
if (state.scope_lock.scope_hash === scopeHash) {
|
|
131
|
+
return state.scope_lock;
|
|
132
|
+
}
|
|
133
|
+
throw new Error(
|
|
134
|
+
`Scope already locked with hash ${state.scope_lock.scope_hash}. Pass force=true to relock, or call abort() first.`
|
|
135
|
+
);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
if (!state || state.completed || force) {
|
|
139
|
+
state = emptyState(now);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
state.scope_lock = {
|
|
143
|
+
scope_hash: scopeHash,
|
|
144
|
+
scope_text: scopeText.trim(),
|
|
145
|
+
acceptance_criteria: acceptanceCriteria,
|
|
146
|
+
allowed_paths: allowedPaths,
|
|
147
|
+
locked_at: now.toISOString(),
|
|
148
|
+
};
|
|
149
|
+
state.updated_at = now.toISOString();
|
|
150
|
+
state.self_verify_passes = [];
|
|
151
|
+
state.completed = false;
|
|
152
|
+
state.completed_at = null;
|
|
153
|
+
state.audit_token = null;
|
|
154
|
+
|
|
155
|
+
writeState(state, projectRoot);
|
|
156
|
+
appendAudit(
|
|
157
|
+
{
|
|
158
|
+
ts: now.toISOString(),
|
|
159
|
+
event: 'lock_scope',
|
|
160
|
+
strict_session_id: state.strict_session_id,
|
|
161
|
+
scope_hash: scopeHash,
|
|
162
|
+
scope_text: state.scope_lock.scope_text,
|
|
163
|
+
acceptance_criteria: acceptanceCriteria,
|
|
164
|
+
allowed_paths: allowedPaths,
|
|
165
|
+
},
|
|
166
|
+
projectRoot
|
|
167
|
+
);
|
|
168
|
+
|
|
169
|
+
return state.scope_lock;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
export function selfVerify({
|
|
173
|
+
findings = [],
|
|
174
|
+
verdict,
|
|
175
|
+
projectRoot,
|
|
176
|
+
now = new Date(),
|
|
177
|
+
} = {}) {
|
|
178
|
+
const state = readState(projectRoot);
|
|
179
|
+
if (!state || !state.active) {
|
|
180
|
+
throw new Error('No active strict session. Call lockScope() first.');
|
|
181
|
+
}
|
|
182
|
+
if (!state.scope_lock) {
|
|
183
|
+
throw new Error('Scope is not locked. Call lockScope() first.');
|
|
184
|
+
}
|
|
185
|
+
if (state.completed) {
|
|
186
|
+
throw new Error(
|
|
187
|
+
'Strict session already completed. Call abort() or lockScope({force:true}) to restart.'
|
|
188
|
+
);
|
|
189
|
+
}
|
|
190
|
+
if (!['ok', 'gap'].includes(verdict)) {
|
|
191
|
+
throw new Error('verdict must be "ok" (no gap) or "gap" (gap detected).');
|
|
192
|
+
}
|
|
193
|
+
if (verdict === 'gap' && (!Array.isArray(findings) || findings.length === 0)) {
|
|
194
|
+
throw new Error('When verdict is "gap", findings must be a non-empty array of strings.');
|
|
195
|
+
}
|
|
196
|
+
if (!Array.isArray(findings)) {
|
|
197
|
+
throw new Error('findings must be an array of strings (can be empty when verdict is "ok").');
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
const passNumber = state.self_verify_passes.length + 1;
|
|
201
|
+
const passEntry = {
|
|
202
|
+
pass: passNumber,
|
|
203
|
+
completed_at: now.toISOString(),
|
|
204
|
+
findings: findings.map((f) => String(f)),
|
|
205
|
+
verdict,
|
|
206
|
+
};
|
|
207
|
+
state.self_verify_passes.push(passEntry);
|
|
208
|
+
state.updated_at = now.toISOString();
|
|
209
|
+
|
|
210
|
+
writeState(state, projectRoot);
|
|
211
|
+
appendAudit(
|
|
212
|
+
{
|
|
213
|
+
ts: now.toISOString(),
|
|
214
|
+
event: 'self_verify',
|
|
215
|
+
strict_session_id: state.strict_session_id,
|
|
216
|
+
scope_hash: state.scope_lock.scope_hash,
|
|
217
|
+
pass: passNumber,
|
|
218
|
+
verdict,
|
|
219
|
+
findings: passEntry.findings,
|
|
220
|
+
},
|
|
221
|
+
projectRoot
|
|
222
|
+
);
|
|
223
|
+
|
|
224
|
+
return {
|
|
225
|
+
pass_count: state.self_verify_passes.length,
|
|
226
|
+
pass: passNumber,
|
|
227
|
+
verdict,
|
|
228
|
+
remaining_passes: Math.max(0, MIN_SELF_VERIFY_PASSES - state.self_verify_passes.length),
|
|
229
|
+
};
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
export function complete({ projectRoot, now = new Date() } = {}) {
|
|
233
|
+
const state = readState(projectRoot);
|
|
234
|
+
if (!state || !state.active) {
|
|
235
|
+
throw new Error('No active strict session.');
|
|
236
|
+
}
|
|
237
|
+
if (!state.scope_lock) {
|
|
238
|
+
throw new Error('Scope is not locked.');
|
|
239
|
+
}
|
|
240
|
+
if (state.completed) {
|
|
241
|
+
throw new Error('Strict session already completed.');
|
|
242
|
+
}
|
|
243
|
+
const passCount = state.self_verify_passes.length;
|
|
244
|
+
if (passCount < MIN_SELF_VERIFY_PASSES) {
|
|
245
|
+
throw new Error(
|
|
246
|
+
`Cannot complete: ${passCount}/${MIN_SELF_VERIFY_PASSES} self-verify passes done. Run selfVerify() at least ${MIN_SELF_VERIFY_PASSES - passCount} more times.`
|
|
247
|
+
);
|
|
248
|
+
}
|
|
249
|
+
const lastPass = state.self_verify_passes[state.self_verify_passes.length - 1];
|
|
250
|
+
if (lastPass.verdict !== 'ok') {
|
|
251
|
+
throw new Error(
|
|
252
|
+
`Cannot complete: last self-verify pass returned verdict="${lastPass.verdict}". Final pass must be "ok" (zero gaps).`
|
|
253
|
+
);
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
const auditEntries = readAuditLog(projectRoot);
|
|
257
|
+
const auditPayload = JSON.stringify({
|
|
258
|
+
scope_hash: state.scope_lock.scope_hash,
|
|
259
|
+
audit: auditEntries,
|
|
260
|
+
ts: now.toISOString(),
|
|
261
|
+
});
|
|
262
|
+
const auditToken = crypto
|
|
263
|
+
.createHash('sha256')
|
|
264
|
+
.update(auditPayload)
|
|
265
|
+
.digest('hex')
|
|
266
|
+
.slice(0, 24);
|
|
267
|
+
|
|
268
|
+
state.completed = true;
|
|
269
|
+
state.completed_at = now.toISOString();
|
|
270
|
+
state.audit_token = auditToken;
|
|
271
|
+
state.updated_at = now.toISOString();
|
|
272
|
+
|
|
273
|
+
writeState(state, projectRoot);
|
|
274
|
+
appendAudit(
|
|
275
|
+
{
|
|
276
|
+
ts: now.toISOString(),
|
|
277
|
+
event: 'complete',
|
|
278
|
+
strict_session_id: state.strict_session_id,
|
|
279
|
+
scope_hash: state.scope_lock.scope_hash,
|
|
280
|
+
pass_count: passCount,
|
|
281
|
+
audit_token: auditToken,
|
|
282
|
+
},
|
|
283
|
+
projectRoot
|
|
284
|
+
);
|
|
285
|
+
|
|
286
|
+
return {
|
|
287
|
+
audit_token: auditToken,
|
|
288
|
+
scope_hash: state.scope_lock.scope_hash,
|
|
289
|
+
pass_count: passCount,
|
|
290
|
+
completed_at: state.completed_at,
|
|
291
|
+
};
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
export function getStatus({ projectRoot } = {}) {
|
|
295
|
+
const state = readState(projectRoot);
|
|
296
|
+
if (!state) {
|
|
297
|
+
return {
|
|
298
|
+
active: false,
|
|
299
|
+
scope_locked: false,
|
|
300
|
+
pass_count: 0,
|
|
301
|
+
completed: false,
|
|
302
|
+
min_passes: MIN_SELF_VERIFY_PASSES,
|
|
303
|
+
};
|
|
304
|
+
}
|
|
305
|
+
return {
|
|
306
|
+
active: state.active,
|
|
307
|
+
strict_session_id: state.strict_session_id,
|
|
308
|
+
scope_locked: Boolean(state.scope_lock),
|
|
309
|
+
scope_hash: state.scope_lock ? state.scope_lock.scope_hash : null,
|
|
310
|
+
scope_text: state.scope_lock ? state.scope_lock.scope_text : null,
|
|
311
|
+
acceptance_criteria: state.scope_lock ? state.scope_lock.acceptance_criteria : [],
|
|
312
|
+
allowed_paths: state.scope_lock ? state.scope_lock.allowed_paths : [],
|
|
313
|
+
pass_count: state.self_verify_passes.length,
|
|
314
|
+
passes: state.self_verify_passes,
|
|
315
|
+
min_passes: MIN_SELF_VERIFY_PASSES,
|
|
316
|
+
completed: state.completed,
|
|
317
|
+
completed_at: state.completed_at,
|
|
318
|
+
audit_token: state.audit_token,
|
|
319
|
+
updated_at: state.updated_at,
|
|
320
|
+
};
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
export function abort({ reason, projectRoot, now = new Date() } = {}) {
|
|
324
|
+
const state = readState(projectRoot);
|
|
325
|
+
if (!state) {
|
|
326
|
+
throw new Error('No strict session to abort.');
|
|
327
|
+
}
|
|
328
|
+
state.active = false;
|
|
329
|
+
state.updated_at = now.toISOString();
|
|
330
|
+
writeState(state, projectRoot);
|
|
331
|
+
appendAudit(
|
|
332
|
+
{
|
|
333
|
+
ts: now.toISOString(),
|
|
334
|
+
event: 'abort',
|
|
335
|
+
strict_session_id: state.strict_session_id,
|
|
336
|
+
scope_hash: state.scope_lock ? state.scope_lock.scope_hash : null,
|
|
337
|
+
reason: reason || null,
|
|
338
|
+
},
|
|
339
|
+
projectRoot
|
|
340
|
+
);
|
|
341
|
+
return { aborted: true, strict_session_id: state.strict_session_id };
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
export function checkAuditTrail({
|
|
345
|
+
scopeHash,
|
|
346
|
+
windowSeconds = 600,
|
|
347
|
+
projectRoot,
|
|
348
|
+
now = new Date(),
|
|
349
|
+
} = {}) {
|
|
350
|
+
const state = readState(projectRoot);
|
|
351
|
+
if (!state || !state.completed) {
|
|
352
|
+
return {
|
|
353
|
+
ok: false,
|
|
354
|
+
reason: 'no_completed_session',
|
|
355
|
+
detail: 'No completed strict session found in state.json.',
|
|
356
|
+
};
|
|
357
|
+
}
|
|
358
|
+
if (scopeHash && state.scope_lock && state.scope_lock.scope_hash !== scopeHash) {
|
|
359
|
+
return {
|
|
360
|
+
ok: false,
|
|
361
|
+
reason: 'scope_mismatch',
|
|
362
|
+
detail: `Expected scope_hash=${scopeHash}, found ${state.scope_lock.scope_hash}.`,
|
|
363
|
+
};
|
|
364
|
+
}
|
|
365
|
+
if (state.self_verify_passes.length < MIN_SELF_VERIFY_PASSES) {
|
|
366
|
+
return {
|
|
367
|
+
ok: false,
|
|
368
|
+
reason: 'insufficient_passes',
|
|
369
|
+
detail: `Only ${state.self_verify_passes.length}/${MIN_SELF_VERIFY_PASSES} self-verify passes.`,
|
|
370
|
+
};
|
|
371
|
+
}
|
|
372
|
+
const completedAt = new Date(state.completed_at).getTime();
|
|
373
|
+
const ageSeconds = (now.getTime() - completedAt) / 1000;
|
|
374
|
+
if (ageSeconds > windowSeconds) {
|
|
375
|
+
return {
|
|
376
|
+
ok: false,
|
|
377
|
+
reason: 'audit_expired',
|
|
378
|
+
detail: `Completion is ${Math.round(ageSeconds)}s old, max allowed is ${windowSeconds}s.`,
|
|
379
|
+
};
|
|
380
|
+
}
|
|
381
|
+
return {
|
|
382
|
+
ok: true,
|
|
383
|
+
audit_token: state.audit_token,
|
|
384
|
+
scope_hash: state.scope_lock.scope_hash,
|
|
385
|
+
pass_count: state.self_verify_passes.length,
|
|
386
|
+
completed_at: state.completed_at,
|
|
387
|
+
age_seconds: Math.round(ageSeconds),
|
|
388
|
+
};
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
export const MIN_PASSES = MIN_SELF_VERIFY_PASSES;
|