@stravica-ai/digital-operator-pa 0.1.55 → 0.1.57
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/bin/pa-operator-attendance +7 -0
- package/bin/pa-worker-launcher +7 -0
- package/dist/engineering-pa-cli/bin/pa-operator-attendance +19 -0
- package/dist/engineering-pa-cli/bin/pa-worker-launcher +14 -0
- package/dist/engineering-pa-cli/config/coordinator-allowlist.json +2 -0
- package/dist/engineering-pa-cli/package.json +3 -1
- package/dist/engineering-pa-cli/src/operator-attendance/cli.mjs +420 -0
- package/dist/engineering-pa-cli/src/pa/work-cli.mjs +116 -0
- package/dist/engineering-pa-cli/src/research/engine/finalise.mjs +14 -4
- package/dist/engineering-pa-cli/src/research/workflows/research-deep.js +15 -5
- package/dist/engineering-pa-cli/src/research/workflows/research-normal.js +60 -14
- package/dist/engineering-pa-cli/src/research/wrappers/normal.mjs +46 -10
- package/dist/engineering-pa-cli/src/worker-launcher/cli.mjs +210 -0
- package/dist/engineering-pa-core/knowledge/delegation-dispatch/dispatch-envelope.md +19 -0
- package/dist/engineering-pa-core/knowledge/knowledge-memory/knowledge-retrieval-discipline.md +4 -0
- package/dist/engineering-pa-core/knowledge/knowledge-memory/staging-and-observations.md +4 -0
- package/dist/engineering-pa-core/roles/agentic-workflow-designer/definition.md +1 -1
- package/dist/engineering-pa-core/src/attendance/grant.mjs +185 -0
- package/dist/engineering-pa-core/src/distill/prompt.mjs +9 -0
- package/dist/engineering-pa-core/src/distill/run.mjs +9 -1
- package/dist/engineering-pa-core/src/slice/prompt.mjs +7 -0
- package/dist/engineering-pa-core/src/slice/run.mjs +8 -1
- package/dist/engineering-pa-library-cli/README.md +69 -13
- package/dist/engineering-pa-library-cli/package.json +1 -1
- package/dist/engineering-pa-library-cli/src/cli.mjs +105 -5
- package/dist/engineering-pa-scaffold/src/generator.mjs +32 -0
- package/dist/engineering-pa-workspace/app/assets/brand/icons/app-icon-192.png +0 -0
- package/dist/engineering-pa-workspace/app/assets/brand/icons/app-icon-512.png +0 -0
- package/dist/engineering-pa-workspace/app/assets/brand/icons/apple-touch-icon.png +0 -0
- package/dist/engineering-pa-workspace/app/assets/brand/icons/favicon-16.png +0 -0
- package/dist/engineering-pa-workspace/app/assets/brand/icons/favicon-32.png +0 -0
- package/dist/engineering-pa-workspace/app/assets/brand/icons/favicon-48.png +0 -0
- package/dist/engineering-pa-workspace/app/assets/brand/icons/favicon.ico +0 -0
- package/dist/engineering-pa-workspace/app/assets/brand/icons/favicon.svg +1 -0
- package/dist/engineering-pa-workspace/app/assets/brand/icons/maskable-icon-512.png +0 -0
- package/dist/engineering-pa-workspace/app/assets/brand/icons/maskable-icon.svg +1 -0
- package/dist/engineering-pa-workspace/app/assets/brand/icons/safari-pinned-tab.svg +1 -0
- package/dist/engineering-pa-workspace/app/assets/brand/stravica-logo-dark.svg +1 -0
- package/dist/engineering-pa-workspace/app/assets/brand/stravica-logo-light.svg +1 -0
- package/dist/engineering-pa-workspace/app/assets/brand/stravica-mark.svg +1 -0
- package/dist/engineering-pa-workspace/app/assets/modal.js +17 -17
- package/dist/engineering-pa-workspace/app/assets/theme.css +332 -99
- package/dist/engineering-pa-workspace/app/assets/theme.js +259 -107
- package/dist/engineering-pa-workspace/app/banner.js +16 -12
- package/dist/engineering-pa-workspace/app/delegations.html +3 -3
- package/dist/engineering-pa-workspace/app/home.html +2 -2
- package/dist/engineering-pa-workspace/app/index.html +6 -3
- package/dist/engineering-pa-workspace/app/knowledge-map.html +2 -2
- package/dist/engineering-pa-workspace/app/knowledge.html +6 -6
- package/dist/engineering-pa-workspace/app/notes.html +2 -2
- package/dist/engineering-pa-workspace/app/onboarding.html +12 -11
- package/dist/engineering-pa-workspace/app/profile.html +4 -4
- package/dist/engineering-pa-workspace/app/project-detail.html +18 -18
- package/dist/engineering-pa-workspace/app/projects.html +4 -4
- package/dist/engineering-pa-workspace/app/repos.html +15 -14
- package/dist/engineering-pa-workspace/app/review-annotator.js +38 -38
- package/dist/engineering-pa-workspace/app/reviews.html +7 -7
- package/dist/engineering-pa-workspace/app/schedules.html +11 -11
- package/dist/engineering-pa-workspace/app/secrets.html +6 -6
- package/dist/engineering-pa-workspace/app/staging.html +15 -15
- package/dist/engineering-pa-workspace/app/tasks.html +4 -4
- package/dist/engineering-pa-workspace/app/viewer.html +9 -9
- package/dist/engineering-pa-workspace/index.mjs +2 -0
- package/dist/engineering-pa-workspace/src/server.mjs +6 -6
- package/dist/engineering-pa-workspace/src/workspace-page-contract.mjs +47 -19
- package/package.json +4 -2
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import('../dist/engineering-pa-cli/src/operator-attendance/cli.mjs')
|
|
3
|
+
.then(async (m) => process.exit(await m.main(process.argv.slice(2))))
|
|
4
|
+
.catch((err) => {
|
|
5
|
+
process.stderr.write(`pa-operator-attendance: fatal error loading hook (${err && err.message}); failing open\n`);
|
|
6
|
+
process.exit(1);
|
|
7
|
+
});
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import('../dist/engineering-pa-cli/src/worker-launcher/cli.mjs')
|
|
3
|
+
.then(async (m) => process.exit(await m.main(process.argv.slice(2))))
|
|
4
|
+
.catch((err) => {
|
|
5
|
+
process.stderr.write(`pa-worker-launcher: fatal error (${err && err.message})\n`);
|
|
6
|
+
process.exit(2);
|
|
7
|
+
});
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// pa-operator-attendance - operator-presence hook for Claude Code.
|
|
3
|
+
//
|
|
4
|
+
// Two subcommands wired into the scaffolded .claude/settings.json
|
|
5
|
+
// (session-start, pre-tool-use). See src/operator-attendance/cli.mjs
|
|
6
|
+
// for the behaviour, the exit-code contract (hook contract, not the
|
|
7
|
+
// repo's own 0/1/2/3), and the observe-vs-enforce switch.
|
|
8
|
+
//
|
|
9
|
+
// Fail-closed is handled inside main(); this top-level .catch() exists
|
|
10
|
+
// for the pathological case where even the dynamic import() fails
|
|
11
|
+
// (corrupted install). The hook contract reads an unexpected exit code
|
|
12
|
+
// as non-blocking, so a partial install does not block every tool call
|
|
13
|
+
// estate-wide: exit 1 and log, same as the inner malformed-input case.
|
|
14
|
+
import('../src/operator-attendance/cli.mjs')
|
|
15
|
+
.then(async (m) => process.exit(await m.main(process.argv.slice(2))))
|
|
16
|
+
.catch((err) => {
|
|
17
|
+
process.stderr.write(`pa-operator-attendance: fatal error loading hook (${err && err.message}); failing open\n`);
|
|
18
|
+
process.exit(1);
|
|
19
|
+
});
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// pa-worker-launcher - mint a signed PA_WORKER_GRANT and spawn an
|
|
3
|
+
// unattended `claude -p` child session for a dispatch. See
|
|
4
|
+
// src/worker-launcher/cli.mjs for the full behaviour and exit-code
|
|
5
|
+
// contract (CONTRIBUTING.md: 0 ok / 1 usage / 2 operational / 3 IO).
|
|
6
|
+
//
|
|
7
|
+
// The hook contract covers the SessionStart + PreToolUse legs; this
|
|
8
|
+
// bin is a normal estate CLI and uses the standard exit-code contract.
|
|
9
|
+
import('../src/worker-launcher/cli.mjs')
|
|
10
|
+
.then(async (m) => process.exit(await m.main(process.argv.slice(2))))
|
|
11
|
+
.catch((err) => {
|
|
12
|
+
process.stderr.write(`pa-worker-launcher: fatal error (${err && err.message})\n`);
|
|
13
|
+
process.exit(2);
|
|
14
|
+
});
|
|
@@ -75,6 +75,7 @@
|
|
|
75
75
|
"hardDeny": {
|
|
76
76
|
"_comment": "Checked before any allow pattern (core or overlay), for both Bash commands and Read/Write paths. Overlays cannot add, remove, or shadow entries here — see policy.mjs. Not a security sandbox (a determined caller can still smuggle a second command past a naive prefix regex — see bashChainGuard below for the mitigation), just a mechanical backstop against the most obviously destructive one-liners, the specific 'obviously-destructive gh verbs' the spec calls out, and reads/writes of credential material even if some overlay pattern would otherwise match it.",
|
|
77
77
|
"bashPatterns": [
|
|
78
|
+
"^\\s*claude(\\s+[^-\\s]|\\s*$)",
|
|
78
79
|
"rm\\s+-rf\\b",
|
|
79
80
|
"curl[^\\n]*\\|\\s*(sh|bash|zsh)\\b",
|
|
80
81
|
"wget[^\\n]*\\|\\s*(sh|bash|zsh)\\b",
|
|
@@ -93,6 +94,7 @@
|
|
|
93
94
|
],
|
|
94
95
|
"bashChainGuardPattern": "[;&|`<>\\n]|\\$\\(",
|
|
95
96
|
"bashChainGuardNote": "Tier2 Bash commands containing a shell-chaining metacharacter (;, &&, ||, |, backtick, $(...), <, >) or an embedded newline are blocked outright even if the leading command (or leading line) matches an allow pattern — a prefix match on 'pa knowledge search foo && rm -rf ~' must not slip through, nor must 'pa knowledge search foo\\nsudo apt-get purge -y openssh-server' or a redirect like 'echo foo > ~/.ssh/authorized_keys'. False positives (e.g. a quoted '&&' inside a search string) are an accepted cost; the hook always suggests a dispatch alternative.",
|
|
97
|
+
"claudeLaunchNote": "The leading `^\\s*claude(\\s+[^-\\s]|\\s*$)` entry denies raw agent-CLI launches from Bash: bare `claude`, `claude help foo`, `claude -p <...>`, `claude mcp list`, any `claude <word>` form that would start a full session that boots the project PA. Flag-only inspection forms (`claude --help`, `claude --version`, `claude --mcp-list`, `claude -v`, `claude -h`) pass through because the character after the space is `-`. A dispatch that genuinely needs its own child session goes through pa-worker-launcher, which carries a signed PA_WORKER_GRANT; the pa-operator-attendance PreToolUse hook enforces the same deny with the launcher-grant exemption.",
|
|
96
98
|
"pathPatterns": [
|
|
97
99
|
"\\.vault/",
|
|
98
100
|
"secrets\\.enc\\.json$",
|
|
@@ -17,7 +17,9 @@
|
|
|
17
17
|
"smoke": "bin/smoke",
|
|
18
18
|
"launch": "bin/launch",
|
|
19
19
|
"research": "bin/research",
|
|
20
|
-
"pa-coordinator-hook": "bin/pa-coordinator-hook"
|
|
20
|
+
"pa-coordinator-hook": "bin/pa-coordinator-hook",
|
|
21
|
+
"pa-operator-attendance": "bin/pa-operator-attendance",
|
|
22
|
+
"pa-worker-launcher": "bin/pa-worker-launcher"
|
|
21
23
|
},
|
|
22
24
|
"dependencies": {
|
|
23
25
|
"@stravica-ai/engineering-pa-core": "workspace:*",
|
|
@@ -0,0 +1,420 @@
|
|
|
1
|
+
// cli.mjs - pa-operator-attendance entry point.
|
|
2
|
+
//
|
|
3
|
+
// Two subcommands run from one program:
|
|
4
|
+
//
|
|
5
|
+
// `pa-operator-attendance session-start`
|
|
6
|
+
// Wired into Claude Code's SessionStart hook. Computes the attendance
|
|
7
|
+
// stamp (TTY + env + no grant), persists it to a per-session cache,
|
|
8
|
+
// and emits a one-line `additionalContext` block carrying
|
|
9
|
+
// `operator-present: true|false[...]` for the loaded agent to read as
|
|
10
|
+
// the first line of its workState context.
|
|
11
|
+
//
|
|
12
|
+
// `pa-operator-attendance pre-tool-use`
|
|
13
|
+
// Wired into Claude Code's PreToolUse hook, matcher Bash + Edit +
|
|
14
|
+
// Write + NotebookEdit. Reads the attendance stamp (recomputing if
|
|
15
|
+
// the cache is missing), denies raw `claude <word>` launches
|
|
16
|
+
// regardless of attendance, denies estate-mutating tool calls on an
|
|
17
|
+
// unattended session with no grant, and otherwise falls through.
|
|
18
|
+
//
|
|
19
|
+
// Observe-vs-enforce switch: `PA_OPERATOR_ATTENDANCE_MODE` env var.
|
|
20
|
+
// observe (default): every would-deny is appended as one JSONL line
|
|
21
|
+
// to the decision log; the hook exits 0 and the call proceeds. Used
|
|
22
|
+
// for one point release before the default flips to enforce so both
|
|
23
|
+
// estates get a week of real-traffic decisions before the gate goes
|
|
24
|
+
// live.
|
|
25
|
+
// enforce: would-denies become real exit-2 denies with stderr
|
|
26
|
+
// messages the model sees.
|
|
27
|
+
//
|
|
28
|
+
// Exit codes follow Claude Code's hook contract, not the repo's own
|
|
29
|
+
// 0/1/2/3 CLI convention: 0 = allow / stamp emitted, 2 = block (enforce),
|
|
30
|
+
// 1 = non-blocking error (malformed input, unexpected failure).
|
|
31
|
+
//
|
|
32
|
+
// Zero dependencies beyond node stdlib and the shared grant module in
|
|
33
|
+
// `@stravica-ai/engineering-pa-core/src/attendance/grant.mjs`.
|
|
34
|
+
|
|
35
|
+
import { appendFile, mkdir, readFile, writeFile } from 'node:fs/promises';
|
|
36
|
+
import { dirname, join } from 'node:path';
|
|
37
|
+
import { tmpdir } from 'node:os';
|
|
38
|
+
import {
|
|
39
|
+
loadOrMintHostKey,
|
|
40
|
+
verifyGrant,
|
|
41
|
+
resolveKeyPath,
|
|
42
|
+
} from '../../../engineering-pa-core/src/attendance/grant.mjs';
|
|
43
|
+
import { resolveHome } from '../../../engineering-pa-core/src/home/resolve.mjs';
|
|
44
|
+
|
|
45
|
+
const USAGE = `pa-operator-attendance: operator-presence gate for Claude Code hooks.
|
|
46
|
+
|
|
47
|
+
Two subcommands wired into the scaffolded .claude/settings.json:
|
|
48
|
+
|
|
49
|
+
session-start SessionStart hook: stamp attendance, emit the
|
|
50
|
+
operator-present line, cache the stamp for the
|
|
51
|
+
PreToolUse leg.
|
|
52
|
+
pre-tool-use PreToolUse hook (Bash + Edit + Write + NotebookEdit):
|
|
53
|
+
deny raw agent-CLI launches, deny estate-mutating
|
|
54
|
+
calls on an unattended session without a grant.
|
|
55
|
+
|
|
56
|
+
Environment:
|
|
57
|
+
PA_OPERATOR_ATTENDANCE_MODE=observe|enforce default: observe
|
|
58
|
+
PA_WORKER_GRANT=<dispatchId>.<unixTs>.<hmac> minted by pa-worker-launcher
|
|
59
|
+
PA_ATTENDANCE_KEY_PATH=/path/to/key override host-key location
|
|
60
|
+
PA_ATTENDANCE_LOG_DIR=/path/to/log/dir override decision-log dir
|
|
61
|
+
|
|
62
|
+
Decision log (observe mode): one JSONL line per would-deny at
|
|
63
|
+
<logDir>/attendance-decisions.jsonl. Rejection log: a hostile grant lands
|
|
64
|
+
at <logDir>/attendance-rejects.log.
|
|
65
|
+
|
|
66
|
+
Usage:
|
|
67
|
+
pa-operator-attendance session-start
|
|
68
|
+
pa-operator-attendance pre-tool-use
|
|
69
|
+
pa-operator-attendance --help
|
|
70
|
+
`;
|
|
71
|
+
|
|
72
|
+
const RAW_CLAUDE_PATTERN = /^\s*claude(\s+[^-\s]|\s*$)/;
|
|
73
|
+
|
|
74
|
+
// Bash commands denied for an unattended + no-grant session. Any line
|
|
75
|
+
// matching one of these is a durable estate side effect: a commit, a
|
|
76
|
+
// push, a write through the pa-family CLI, a redirect, a tee, an rm.
|
|
77
|
+
// Read-only verbs (git status, git log, ls, cat, grep, etc.) are not
|
|
78
|
+
// listed and pass through.
|
|
79
|
+
const UNATTENDED_MUTATE_PATTERNS = [
|
|
80
|
+
/^\s*git\s+(commit|push|merge|rebase|cherry-pick|reset|checkout|branch|tag|stash|am|revert|pull)\b/,
|
|
81
|
+
/^\s*gh\s+(pr\s+(create|edit|merge|close|comment|review)|repo\s+(create|edit|delete|archive)|issue\s+(create|edit|close|comment)|release\s+(create|edit|delete)|workflow\s+(enable|disable|run)|secret\s+(set|remove))\b/,
|
|
82
|
+
/^\s*(pnpm\s+exec\s+)?pa\s+(work|project|library|relay|autonomy|content-pub|secrets|scheduler)\s+(?!(?:help|list|ls|get|show|view|status|tail|find|search|export|path|inspect|whoami|context|overview|today|related|domain|orphans|read|print))/,
|
|
83
|
+
/^\s*(pnpm\s+exec\s+)?library\s+(put|flush|refresh-clones|up-merge)\b/,
|
|
84
|
+
/^\s*(pnpm\s+exec\s+)?claude-relay\s+(send|publish|post)\b/,
|
|
85
|
+
/^\s*rm\b/,
|
|
86
|
+
/^\s*mv\b/,
|
|
87
|
+
/^\s*cp\b/,
|
|
88
|
+
/^\s*mkdir\b/,
|
|
89
|
+
/^\s*touch\b/,
|
|
90
|
+
/^\s*(ln|chmod|chown)\b/,
|
|
91
|
+
/^\s*(tee|dd)\b/,
|
|
92
|
+
/^\s*(apt|apt-get|brew|npm|pnpm|yarn)\s+(install|add|remove|uninstall|upgrade|update|publish)\b/,
|
|
93
|
+
/[^<>]>\s*\S/, // `foo > bar`, `foo 2> bar`: any single-redirect write
|
|
94
|
+
/>>\s*\S/, // append redirect
|
|
95
|
+
];
|
|
96
|
+
|
|
97
|
+
export async function main(argv = [], {
|
|
98
|
+
stdin = process.stdin,
|
|
99
|
+
stdout = process.stdout,
|
|
100
|
+
stderr = process.stderr,
|
|
101
|
+
env = process.env,
|
|
102
|
+
now = () => new Date(),
|
|
103
|
+
} = {}) {
|
|
104
|
+
if (argv.includes('-h') || argv.includes('--help')) {
|
|
105
|
+
stderr.write(USAGE);
|
|
106
|
+
return 0;
|
|
107
|
+
}
|
|
108
|
+
const sub = argv[0];
|
|
109
|
+
if (!sub) {
|
|
110
|
+
stderr.write('pa-operator-attendance: missing subcommand (session-start | pre-tool-use)\n');
|
|
111
|
+
return 1;
|
|
112
|
+
}
|
|
113
|
+
try {
|
|
114
|
+
if (sub === 'session-start') {
|
|
115
|
+
return await runSessionStart({ stdin, stdout, stderr, env, now });
|
|
116
|
+
}
|
|
117
|
+
if (sub === 'pre-tool-use') {
|
|
118
|
+
return await runPreToolUse({ stdin, stdout, stderr, env, now });
|
|
119
|
+
}
|
|
120
|
+
stderr.write(`pa-operator-attendance: unknown subcommand "${sub}"\n`);
|
|
121
|
+
return 1;
|
|
122
|
+
} catch (err) {
|
|
123
|
+
// A hook that throws unhandled is worse than a degraded one: a
|
|
124
|
+
// SessionStart failure blocks the boot, a PreToolUse failure blocks
|
|
125
|
+
// every tool call. Exit 1 (non-blocking per the hooks contract) and
|
|
126
|
+
// leave a stderr note the transcript will pick up.
|
|
127
|
+
stderr.write(`pa-operator-attendance: ${sub || 'unknown'}: unexpected error (${err && err.message}); failing open\n`);
|
|
128
|
+
return 1;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// --- session-start ---------------------------------------------------------
|
|
133
|
+
|
|
134
|
+
async function runSessionStart({ stdin, stdout, stderr, env, now }) {
|
|
135
|
+
const payload = await readPayload(stdin);
|
|
136
|
+
// SessionStart has no required fields we gate on; we still parse it so
|
|
137
|
+
// a payload with `session_id` can key the cache properly.
|
|
138
|
+
const sessionId = (payload && payload.session_id) || env.CLAUDE_CODE_SESSION_ID || 'unknown';
|
|
139
|
+
|
|
140
|
+
// Attendance detection. The empirical probe for w-2026-10-02-dave-018
|
|
141
|
+
// (session-start leg) proved against client 2.1.287 that:
|
|
142
|
+
// - a `claude -p` child always sees CLAUDE_CODE_SESSION_ATTENDED=0
|
|
143
|
+
// (not unset; the client zeroes it explicitly)
|
|
144
|
+
// - a SessionStart hook fired by a `claude -p` child sees both
|
|
145
|
+
// stdin and stdout as non-TTY
|
|
146
|
+
// So the positive-proof gate is: stdout.isTTY AND env === '1' AND no
|
|
147
|
+
// grant. Any other combination is unattended, which is the fail-safe
|
|
148
|
+
// direction (a client bump that strips the var defaults to absent).
|
|
149
|
+
const grantPresent = typeof env.PA_WORKER_GRANT === 'string' && env.PA_WORKER_GRANT.length > 0;
|
|
150
|
+
const envAttended = env.CLAUDE_CODE_SESSION_ATTENDED === '1';
|
|
151
|
+
const stdoutIsTty = Boolean(stdout.isTTY);
|
|
152
|
+
const attended = stdoutIsTty && envAttended && !grantPresent;
|
|
153
|
+
|
|
154
|
+
let stamp;
|
|
155
|
+
if (attended) {
|
|
156
|
+
stamp = { operatorPresent: true, grant: 'none', scope: 'attended' };
|
|
157
|
+
} else if (grantPresent) {
|
|
158
|
+
const key = await loadOrMintHostKeySafe(env);
|
|
159
|
+
const verdict = key
|
|
160
|
+
? verifyGrant({ grant: env.PA_WORKER_GRANT, hostKey: key, now: now() })
|
|
161
|
+
: { ok: false, reason: 'host-key-unavailable' };
|
|
162
|
+
if (verdict.ok) {
|
|
163
|
+
stamp = {
|
|
164
|
+
operatorPresent: false,
|
|
165
|
+
grant: verdict.dispatchId,
|
|
166
|
+
scope: 'dispatch-only',
|
|
167
|
+
grantUnixTs: verdict.unixTs,
|
|
168
|
+
};
|
|
169
|
+
} else {
|
|
170
|
+
stamp = {
|
|
171
|
+
operatorPresent: false,
|
|
172
|
+
grant: 'rejected',
|
|
173
|
+
scope: 'refusal',
|
|
174
|
+
refusal: `grant ${verdict.reason}`,
|
|
175
|
+
};
|
|
176
|
+
await appendRejectLog({ env, sessionId, reason: verdict.reason, now: now() });
|
|
177
|
+
}
|
|
178
|
+
} else {
|
|
179
|
+
stamp = {
|
|
180
|
+
operatorPresent: false,
|
|
181
|
+
grant: 'none',
|
|
182
|
+
scope: 'refusal',
|
|
183
|
+
refusal: 'boot-routine denied, no PA_WORKER_GRANT',
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
await persistStamp({ env, sessionId, stamp, now: now() });
|
|
188
|
+
|
|
189
|
+
// Emit the Claude Code hook JSON envelope. The additionalContext text
|
|
190
|
+
// becomes the first line of the session's workState context, carrying
|
|
191
|
+
// the stamp to any downstream hook (boot-context, relay list) and to
|
|
192
|
+
// the loaded agent per the canon clause.
|
|
193
|
+
const contextLine = formatStampLine(stamp);
|
|
194
|
+
const envelope = {
|
|
195
|
+
hookSpecificOutput: {
|
|
196
|
+
hookEventName: 'SessionStart',
|
|
197
|
+
additionalContext: contextLine + '\n',
|
|
198
|
+
},
|
|
199
|
+
};
|
|
200
|
+
stdout.write(JSON.stringify(envelope) + '\n');
|
|
201
|
+
|
|
202
|
+
// Observe-mode trace: every session-start decision lands as one JSONL
|
|
203
|
+
// line so operators can see how the gate would have behaved on the
|
|
204
|
+
// first point release before the default flips to enforce.
|
|
205
|
+
await appendDecisionLog({
|
|
206
|
+
env,
|
|
207
|
+
now: now(),
|
|
208
|
+
leg: 'session-start',
|
|
209
|
+
decision: 'stamp',
|
|
210
|
+
stamp,
|
|
211
|
+
sessionId,
|
|
212
|
+
attendedSignals: { stdoutIsTty, envAttended, grantPresent },
|
|
213
|
+
});
|
|
214
|
+
return 0;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function formatStampLine(stamp) {
|
|
218
|
+
const parts = [`operator-present: ${stamp.operatorPresent ? 'true' : 'false'}`];
|
|
219
|
+
if (stamp.grant) parts.push(`grant: ${stamp.grant}`);
|
|
220
|
+
if (stamp.scope) parts.push(`scope: ${stamp.scope}`);
|
|
221
|
+
if (stamp.refusal) parts.push(`refusal: ${stamp.refusal}`);
|
|
222
|
+
return parts.join(', ');
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// --- pre-tool-use ----------------------------------------------------------
|
|
226
|
+
|
|
227
|
+
async function runPreToolUse({ stdin, stdout, stderr, env, now }) {
|
|
228
|
+
const mode = env.PA_OPERATOR_ATTENDANCE_MODE === 'enforce' ? 'enforce' : 'observe';
|
|
229
|
+
let payload;
|
|
230
|
+
try {
|
|
231
|
+
payload = await readPayload(stdin);
|
|
232
|
+
} catch (err) {
|
|
233
|
+
stderr.write(`pa-operator-attendance: pre-tool-use: malformed stdin (${err.message}); failing open\n`);
|
|
234
|
+
return 1;
|
|
235
|
+
}
|
|
236
|
+
const toolName = payload && typeof payload.tool_name === 'string' ? payload.tool_name : '';
|
|
237
|
+
const toolInput = (payload && payload.tool_input) || {};
|
|
238
|
+
if (!toolName) {
|
|
239
|
+
stderr.write('pa-operator-attendance: pre-tool-use: payload missing tool_name; failing open\n');
|
|
240
|
+
return 1;
|
|
241
|
+
}
|
|
242
|
+
const sessionId = (payload && payload.session_id) || env.CLAUDE_CODE_SESSION_ID || 'unknown';
|
|
243
|
+
const stamp = await readStamp({ env, sessionId }) || recomputeStamp(env);
|
|
244
|
+
|
|
245
|
+
const bashCommand = toolName === 'Bash' && typeof toolInput.command === 'string'
|
|
246
|
+
? toolInput.command
|
|
247
|
+
: null;
|
|
248
|
+
|
|
249
|
+
// Rule 1: raw agent-CLI launch is denied regardless of attendance.
|
|
250
|
+
// The flag-only forms (`claude --help`, `claude --version`, `claude -v`,
|
|
251
|
+
// `claude -h`) pass; everything else (`claude help foo`, `claude -p`,
|
|
252
|
+
// `claude mcp list`, bare `claude`) is a session launch and must go
|
|
253
|
+
// through the launcher.
|
|
254
|
+
if (bashCommand) {
|
|
255
|
+
for (const line of bashCommand.split('\n')) {
|
|
256
|
+
if (RAW_CLAUDE_PATTERN.test(line)) {
|
|
257
|
+
return await denyOrObserve({
|
|
258
|
+
env, now, mode, stdout, stderr, sessionId, toolName, bashCommand,
|
|
259
|
+
stamp, rule: 'raw-claude-launch', matched: line.trim(),
|
|
260
|
+
message: 'pa-operator-attendance: raw agent-CLI launch denied. A `claude <word>` from a tool call starts a full session that boots the project PA. Use pa-worker-launcher to spawn a child session with a dispatch grant, or an inspection flag form (`claude --help`, `claude --version`).',
|
|
261
|
+
});
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// Rule 2: unattended + no grant denies every estate-mutating call.
|
|
267
|
+
if (!stamp.operatorPresent && stamp.grant === 'none') {
|
|
268
|
+
if (toolName === 'Edit' || toolName === 'Write' || toolName === 'NotebookEdit') {
|
|
269
|
+
return await denyOrObserve({
|
|
270
|
+
env, now, mode, stdout, stderr, sessionId, toolName, bashCommand: null,
|
|
271
|
+
stamp, rule: 'unattended-write', matched: toolName,
|
|
272
|
+
message: `pa-operator-attendance: ${toolName} denied; session is unattended with no PA_WORKER_GRANT; estate side effects are blocked. Allowed: Read, Grep, Glob, and flag-only CLI inspection.`,
|
|
273
|
+
});
|
|
274
|
+
}
|
|
275
|
+
if (bashCommand) {
|
|
276
|
+
for (const line of bashCommand.split('\n')) {
|
|
277
|
+
for (const pattern of UNATTENDED_MUTATE_PATTERNS) {
|
|
278
|
+
if (pattern.test(line)) {
|
|
279
|
+
return await denyOrObserve({
|
|
280
|
+
env, now, mode, stdout, stderr, sessionId, toolName, bashCommand,
|
|
281
|
+
stamp, rule: 'unattended-mutate', matched: line.trim(),
|
|
282
|
+
message: `pa-operator-attendance: Bash command denied; session is unattended with no PA_WORKER_GRANT; estate side effects are blocked (matched "${pattern.source}"). Allowed: read-only inspection.`,
|
|
283
|
+
});
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
// Rule 2a: a rejected grant is hostile, not merely absent. Any tool
|
|
290
|
+
// call on a session with grant: rejected is denied outright until the
|
|
291
|
+
// operator attends or re-mints the grant.
|
|
292
|
+
if (!stamp.operatorPresent && stamp.grant === 'rejected') {
|
|
293
|
+
return await denyOrObserve({
|
|
294
|
+
env, now, mode, stdout, stderr, sessionId, toolName, bashCommand,
|
|
295
|
+
stamp, rule: 'rejected-grant', matched: toolName,
|
|
296
|
+
message: `pa-operator-attendance: ${toolName} denied; session carried a PA_WORKER_GRANT that failed verification; every tool call is blocked until the session attends or re-mints a valid grant.`,
|
|
297
|
+
});
|
|
298
|
+
}
|
|
299
|
+
return 0;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
function recomputeStamp(env) {
|
|
303
|
+
// Fallback when the session-start cache is missing (hook ordering, cold
|
|
304
|
+
// start). Mirror the session-start detection without the key material,
|
|
305
|
+
// which means a grant that LOOKS valid shape-wise is treated as
|
|
306
|
+
// dispatch-only without HMAC verification. Intentional: the hook stays
|
|
307
|
+
// closed if session-start's richer stamp was lost, but it does not
|
|
308
|
+
// fabricate a verdict it does not have evidence for.
|
|
309
|
+
const grantPresent = typeof env.PA_WORKER_GRANT === 'string' && env.PA_WORKER_GRANT.length > 0;
|
|
310
|
+
const envAttended = env.CLAUDE_CODE_SESSION_ATTENDED === '1';
|
|
311
|
+
// In the pre-tool-use leg, stdout is always the hook pipe, so TTY check
|
|
312
|
+
// alone is unreliable. Trust the env signal only when there is no grant.
|
|
313
|
+
if (grantPresent) return { operatorPresent: false, grant: 'present', scope: 'dispatch-only' };
|
|
314
|
+
if (envAttended) return { operatorPresent: true, grant: 'none', scope: 'attended' };
|
|
315
|
+
return { operatorPresent: false, grant: 'none', scope: 'refusal', refusal: 'no stamp cache, env signals absent' };
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
async function denyOrObserve({ env, now, mode, stdout, stderr, sessionId, toolName, bashCommand, stamp, rule, matched, message }) {
|
|
319
|
+
await appendDecisionLog({
|
|
320
|
+
env,
|
|
321
|
+
now: now(),
|
|
322
|
+
leg: 'pre-tool-use',
|
|
323
|
+
decision: mode === 'enforce' ? 'deny' : 'observe',
|
|
324
|
+
rule,
|
|
325
|
+
matched,
|
|
326
|
+
toolName,
|
|
327
|
+
bashCommand,
|
|
328
|
+
stamp,
|
|
329
|
+
sessionId,
|
|
330
|
+
mode,
|
|
331
|
+
});
|
|
332
|
+
if (mode === 'enforce') {
|
|
333
|
+
stderr.write(message + '\n');
|
|
334
|
+
return 2;
|
|
335
|
+
}
|
|
336
|
+
// Observe mode: write a one-line stderr note so the operator can see
|
|
337
|
+
// what would have been denied without the tool call being blocked.
|
|
338
|
+
stderr.write(`pa-operator-attendance: OBSERVE would deny (${rule}: ${matched})\n`);
|
|
339
|
+
return 0;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
// --- helpers ---------------------------------------------------------------
|
|
343
|
+
|
|
344
|
+
async function readPayload(stdin) {
|
|
345
|
+
if (stdin.isTTY) return {};
|
|
346
|
+
const chunks = [];
|
|
347
|
+
for await (const chunk of stdin) chunks.push(chunk);
|
|
348
|
+
if (chunks.length === 0) return {};
|
|
349
|
+
const raw = Buffer.concat(chunks.map((c) => (Buffer.isBuffer(c) ? c : Buffer.from(c)))).toString('utf8');
|
|
350
|
+
if (raw.trim() === '') return {};
|
|
351
|
+
return JSON.parse(raw);
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
async function loadOrMintHostKeySafe(env) {
|
|
355
|
+
try {
|
|
356
|
+
return await loadOrMintHostKey({ keyPath: resolveKeyPath({ env }) });
|
|
357
|
+
} catch {
|
|
358
|
+
return null;
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
function resolveLogDir(env) {
|
|
363
|
+
if (env.PA_ATTENDANCE_LOG_DIR && env.PA_ATTENDANCE_LOG_DIR.length > 0) {
|
|
364
|
+
return env.PA_ATTENDANCE_LOG_DIR;
|
|
365
|
+
}
|
|
366
|
+
return join(resolveHome({ env }), '.local', 'share', 'pa');
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
function resolveStampPath(env, sessionId) {
|
|
370
|
+
const base = env.TMPDIR || tmpdir();
|
|
371
|
+
const safe = sessionId.replace(/[^A-Za-z0-9._-]/g, '_').slice(0, 128);
|
|
372
|
+
return join(base, `pa-attendance-${safe}.json`);
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
async function persistStamp({ env, sessionId, stamp, now }) {
|
|
376
|
+
const path = resolveStampPath(env, sessionId);
|
|
377
|
+
const record = { ...stamp, writtenAt: now.toISOString(), sessionId };
|
|
378
|
+
try {
|
|
379
|
+
await mkdir(dirname(path), { recursive: true });
|
|
380
|
+
await writeFile(path, JSON.stringify(record), { mode: 0o600 });
|
|
381
|
+
} catch {
|
|
382
|
+
// The cache is a convenience for the pre-tool-use leg; losing it
|
|
383
|
+
// means the leg falls back to env recomputation (fail-safe).
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
async function readStamp({ env, sessionId }) {
|
|
388
|
+
const path = resolveStampPath(env, sessionId);
|
|
389
|
+
try {
|
|
390
|
+
const raw = await readFile(path, 'utf8');
|
|
391
|
+
return JSON.parse(raw);
|
|
392
|
+
} catch {
|
|
393
|
+
return null;
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
async function appendDecisionLog({ env, now, ...fields }) {
|
|
398
|
+
const dir = resolveLogDir(env);
|
|
399
|
+
const path = join(dir, 'attendance-decisions.jsonl');
|
|
400
|
+
const line = JSON.stringify({ ts: now.toISOString(), ...fields }) + '\n';
|
|
401
|
+
try {
|
|
402
|
+
await mkdir(dir, { recursive: true });
|
|
403
|
+
await appendFile(path, line);
|
|
404
|
+
} catch {
|
|
405
|
+
// Decision log is best effort; a hook never fails on logging.
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
async function appendRejectLog({ env, sessionId, reason, now }) {
|
|
410
|
+
const dir = resolveLogDir(env);
|
|
411
|
+
const path = join(dir, 'attendance-rejects.log');
|
|
412
|
+
const line = `${now.toISOString()} session=${sessionId} reason=${reason}\n`;
|
|
413
|
+
try {
|
|
414
|
+
await mkdir(dir, { recursive: true });
|
|
415
|
+
await appendFile(path, line);
|
|
416
|
+
} catch {
|
|
417
|
+
// Reject log is best effort too; the stamp is already emitting a
|
|
418
|
+
// refusal downstream.
|
|
419
|
+
}
|
|
420
|
+
}
|
|
@@ -19,10 +19,14 @@
|
|
|
19
19
|
// fall-back-to-default case. Full rationale + tests in
|
|
20
20
|
// `@stravica-ai/engineering-pa-core/src/workItemId/index.mjs`.
|
|
21
21
|
|
|
22
|
+
import { existsSync } from 'node:fs';
|
|
23
|
+
import { join } from 'node:path';
|
|
24
|
+
|
|
22
25
|
import { readPreferences } from '../../../engineering-pa-core/src/preferences.mjs';
|
|
23
26
|
|
|
24
27
|
import { createWork, setWorkStatus, listWork } from './work.mjs';
|
|
25
28
|
import { getRepoRoot } from './repo-root.mjs';
|
|
29
|
+
import { listSubdirs } from './io.mjs';
|
|
26
30
|
|
|
27
31
|
const USAGE = `pa work — manage operator-repo work items
|
|
28
32
|
|
|
@@ -154,6 +158,21 @@ async function doCreate(args) {
|
|
|
154
158
|
process.stderr.write(` accepted flags: ${known}\n`);
|
|
155
159
|
return 1;
|
|
156
160
|
}
|
|
161
|
+
// Project existence gate (w-2026-09-30-dave-005 estate chore). Before this gate,
|
|
162
|
+
// `pa work create --project <nonexistent>` silently minted a work item pointing at
|
|
163
|
+
// a project that did not exist; the mistake surfaced later as an orphan in listings.
|
|
164
|
+
// Refuse loudly with the nearest matches so a typo is a fast fix, not a silent
|
|
165
|
+
// corruption. Only the CLI enforces this — programmatic callers of createWork()
|
|
166
|
+
// stay permissive (test fixtures use --project 'proj-a' without a real project on
|
|
167
|
+
// disk); wider grep found no CLI caller depending on the old behaviour, so no
|
|
168
|
+
// escape hatch is exposed.
|
|
169
|
+
if (typeof flags.project === 'string') {
|
|
170
|
+
const projectErr = await validateProjectExists(flags.project);
|
|
171
|
+
if (projectErr) {
|
|
172
|
+
process.stderr.write(projectErr);
|
|
173
|
+
return 1;
|
|
174
|
+
}
|
|
175
|
+
}
|
|
157
176
|
const priority = flags.priority != null && flags.priority !== true
|
|
158
177
|
? Number(flags.priority)
|
|
159
178
|
: null;
|
|
@@ -226,3 +245,100 @@ async function doList(args) {
|
|
|
226
245
|
for (const r of rows) process.stdout.write(cols.map((c) => pad(r[c], widths[c])).join(' ') + '\n');
|
|
227
246
|
return 0;
|
|
228
247
|
}
|
|
248
|
+
|
|
249
|
+
// ── project-existence validation (w-2026-09-30-dave-005 estate chore) ──────────
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* Levenshtein distance between two short strings. Used to rank nearest project ids
|
|
253
|
+
* for a typo; not exported.
|
|
254
|
+
*/
|
|
255
|
+
function levenshtein(a, b) {
|
|
256
|
+
if (a === b) return 0;
|
|
257
|
+
if (!a.length) return b.length;
|
|
258
|
+
if (!b.length) return a.length;
|
|
259
|
+
const prev = new Array(b.length + 1);
|
|
260
|
+
const curr = new Array(b.length + 1);
|
|
261
|
+
for (let j = 0; j <= b.length; j += 1) prev[j] = j;
|
|
262
|
+
for (let i = 1; i <= a.length; i += 1) {
|
|
263
|
+
curr[0] = i;
|
|
264
|
+
for (let j = 1; j <= b.length; j += 1) {
|
|
265
|
+
const cost = a[i - 1] === b[j - 1] ? 0 : 1;
|
|
266
|
+
curr[j] = Math.min(curr[j - 1] + 1, prev[j] + 1, prev[j - 1] + cost);
|
|
267
|
+
}
|
|
268
|
+
for (let j = 0; j <= b.length; j += 1) prev[j] = curr[j];
|
|
269
|
+
}
|
|
270
|
+
return prev[b.length];
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* List every projects/<id>/ whose project.json exists on disk. Names only, sorted
|
|
275
|
+
* lexicographically. Empty when the projects/ dir is absent (accepted by listSubdirs).
|
|
276
|
+
*/
|
|
277
|
+
async function listExistingProjectIds() {
|
|
278
|
+
const repoRoot = await getRepoRoot();
|
|
279
|
+
const projectsDir = join(repoRoot, 'projects');
|
|
280
|
+
const subdirs = await listSubdirs(projectsDir);
|
|
281
|
+
const ids = [];
|
|
282
|
+
for (const name of subdirs) {
|
|
283
|
+
if (existsSync(join(projectsDir, name, 'project.json'))) ids.push(name);
|
|
284
|
+
}
|
|
285
|
+
ids.sort();
|
|
286
|
+
return ids;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/**
|
|
290
|
+
* Nearest candidates for a missing project id: substring matches first (both
|
|
291
|
+
* directions), then the closest by Levenshtein distance (up to length/2 + 1). At
|
|
292
|
+
* most 5 suggestions; sorted deterministically.
|
|
293
|
+
*/
|
|
294
|
+
function nearestProjectMatches(target, candidates, max = 5) {
|
|
295
|
+
const seen = new Set();
|
|
296
|
+
const out = [];
|
|
297
|
+
const push = (id) => { if (!seen.has(id)) { seen.add(id); out.push(id); } };
|
|
298
|
+
for (const id of candidates) {
|
|
299
|
+
if (id.toLowerCase().includes(target.toLowerCase()) || target.toLowerCase().includes(id.toLowerCase())) {
|
|
300
|
+
push(id);
|
|
301
|
+
}
|
|
302
|
+
if (out.length >= max) break;
|
|
303
|
+
}
|
|
304
|
+
if (out.length < max) {
|
|
305
|
+
const threshold = Math.max(1, Math.floor(target.length / 2) + 1);
|
|
306
|
+
const scored = candidates
|
|
307
|
+
.filter((id) => !seen.has(id))
|
|
308
|
+
.map((id) => ({ id, d: levenshtein(target.toLowerCase(), id.toLowerCase()) }))
|
|
309
|
+
.filter((r) => r.d <= threshold)
|
|
310
|
+
.sort((a, b) => a.d - b.d || a.id.localeCompare(b.id));
|
|
311
|
+
for (const { id } of scored) {
|
|
312
|
+
push(id);
|
|
313
|
+
if (out.length >= max) break;
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
return out;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/**
|
|
320
|
+
* Returns null when the project id resolves to an existing project.json on disk, or
|
|
321
|
+
* a preformatted stderr message (with trailing newline) when it does not. Split so
|
|
322
|
+
* doCreate can print + return exit 1 without repeating the plumbing.
|
|
323
|
+
*/
|
|
324
|
+
async function validateProjectExists(projectId) {
|
|
325
|
+
const repoRoot = await getRepoRoot();
|
|
326
|
+
const path = join(repoRoot, 'projects', projectId, 'project.json');
|
|
327
|
+
if (existsSync(path)) return null;
|
|
328
|
+
let candidates = [];
|
|
329
|
+
try { candidates = await listExistingProjectIds(); } catch { candidates = []; }
|
|
330
|
+
const nearest = nearestProjectMatches(projectId, candidates);
|
|
331
|
+
const lines = [`[pa work create] unknown project: ${projectId}`];
|
|
332
|
+
lines.push(` expected projects/${projectId}/project.json on disk (not found)`);
|
|
333
|
+
if (nearest.length) {
|
|
334
|
+
lines.push(` did you mean: ${nearest.join(', ')}`);
|
|
335
|
+
} else if (candidates.length) {
|
|
336
|
+
lines.push(` existing projects: ${candidates.slice(0, 10).join(', ')}${candidates.length > 10 ? ', ...' : ''}`);
|
|
337
|
+
} else {
|
|
338
|
+
lines.push(' no projects on disk yet — create one with `pa project create` first');
|
|
339
|
+
}
|
|
340
|
+
return lines.map((l) => `${l}\n`).join('');
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
// Exported for tests only — pure helpers, no filesystem side-effects.
|
|
344
|
+
export const __testables = { levenshtein, nearestProjectMatches };
|
|
@@ -134,9 +134,13 @@ export async function degradedSynthAgent(ctx, sq, reason) {
|
|
|
134
134
|
|
|
135
135
|
// Pure coverage computation. Given the planner's sub-question ids and the synth
|
|
136
136
|
// receipts, decide which SQs have a finding (any receipt with written=true), which ran
|
|
137
|
-
// as a degraded write (empty finding,
|
|
138
|
-
// uncovered (no receipt at all
|
|
139
|
-
//
|
|
137
|
+
// as a degraded write (empty finding, covered on disk but zero surviving claims), and
|
|
138
|
+
// which are wholly uncovered (no receipt at all). `complete` is true only when every
|
|
139
|
+
// planned SQ has a receipt AND every receipt carries at least one claim — a degraded
|
|
140
|
+
// zero-claim finding is a coverage gap the wrapper must surface (status='partial'),
|
|
141
|
+
// not silently mark done (w-2026-09-30-dave-005). The wrapper still distinguishes the
|
|
142
|
+
// two failure modes: uncovered aborts finalise; degraded lets the answer.md be
|
|
143
|
+
// written and returns status='partial' with the degraded[] list.
|
|
140
144
|
export function coverageReport(subQuestionIds, synthReceipts) {
|
|
141
145
|
const receiptsBySq = new Map();
|
|
142
146
|
for (const r of synthReceipts || []) {
|
|
@@ -152,7 +156,13 @@ export function coverageReport(subQuestionIds, synthReceipts) {
|
|
|
152
156
|
covered.push(id);
|
|
153
157
|
if ((r.claimCount || 0) === 0) degraded.push(id);
|
|
154
158
|
}
|
|
155
|
-
return {
|
|
159
|
+
return {
|
|
160
|
+
planned,
|
|
161
|
+
covered,
|
|
162
|
+
degraded,
|
|
163
|
+
uncovered,
|
|
164
|
+
complete: uncovered.length === 0 && degraded.length === 0,
|
|
165
|
+
};
|
|
156
166
|
}
|
|
157
167
|
|
|
158
168
|
// -- Finalise (persist gate records, write sources.json + the session answer, round-trip, mark done) --
|