claude-code-session-manager 0.76.0 → 0.78.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/dist/assets/{AgentLibrary-CBx9l4zN.js → AgentLibrary-13pfo8uY.js} +2 -2
- package/dist/assets/{DataModel-Bf0EIE_t.js → DataModel-SUyQbFlg.js} +1 -1
- package/dist/assets/{History-CpdtWhC8.js → History-2GJMS703.js} +2 -2
- package/dist/assets/{Hooks-DyUbMDmg.js → Hooks-DM2nS3RT.js} +2 -2
- package/dist/assets/{HostBilko-By-wIpry.js → HostBilko-BLeC-lpp.js} +1 -1
- package/dist/assets/{Library-CQmo4QVC.js → Library-BaRkU9m0.js} +1 -1
- package/dist/assets/{ListDetail-BQMd6NOm.js → ListDetail-D5scjSKq.js} +1 -1
- package/dist/assets/{MarkdownEditor-DEp43FXX.js → MarkdownEditor-B1lgAo9T.js} +1 -1
- package/dist/assets/{McpServers-CLarzwqA.js → McpServers-BzyThQSM.js} +1 -1
- package/dist/assets/{Memory-B0sCdIy1.js → Memory-7UdaOTtl.js} +6 -6
- package/dist/assets/{Panel-BhWPVOCD.js → Panel-JbTMaOPq.js} +1 -1
- package/dist/assets/{Permissions-Ddlq8T_O.js → Permissions-UBam0bJG.js} +2 -2
- package/dist/assets/{Plugins-D2oA_2Jl.js → Plugins-B3gUDkeb.js} +2 -2
- package/dist/assets/{ProvenanceBadge-DgAgavUM.js → ProvenanceBadge-CeHOub7m.js} +1 -1
- package/dist/assets/SaveBar-BcvQEq6h.js +1 -0
- package/dist/assets/Scheduler-Dc5qiP24.js +14 -0
- package/dist/assets/{ScopeSwitcher-C_zWEtIl.js → ScopeSwitcher-BvGQmw4Y.js} +1 -1
- package/dist/assets/{Settings-2Vx3X5SI.js → Settings-C2dEFb-v.js} +1 -1
- package/dist/assets/{SkillReferenceGraph-BDEUjlTQ.js → SkillReferenceGraph-CIwlosBc.js} +1 -1
- package/dist/assets/{Skills-Cmrz_LeN.js → Skills-C0GzzVrQ.js} +2 -2
- package/dist/assets/{SystemPrompt-DVA1eYDP.js → SystemPrompt-mtGPK8zo.js} +1 -1
- package/dist/assets/TagLibrary-DX54-mpd.js +1 -0
- package/dist/assets/{TiptapBody-DmPc3amD.js → TiptapBody-yADC2RWE.js} +1 -1
- package/dist/assets/{Toggle-zfd5LJkK.js → Toggle-CRxaCYLI.js} +1 -1
- package/dist/assets/{index-DIjnPkRN.css → index-CYhdtisq.css} +1 -1
- package/dist/assets/{index-B_4PNh9T.js → index-D6ymGESc.js} +469 -469
- package/dist/assets/{settingsSchema-B9es6fdA.js → settingsSchema-TtMvT5Sx.js} +1 -1
- package/dist/index.html +2 -2
- package/package.json +8 -2
- package/plugins/session-manager-dev/skills/develop/standards.md +1 -1
- package/scripts/project-pages-logic/dist/logic.cjs +4709 -0
- package/scripts/render-project-pages/dist/renderer.cjs +18900 -0
- package/scripts/render-project-pages.cjs +70 -0
- package/scripts/scheduler-mcp-server.cjs +115 -1
- package/scripts/validate-project-pages-summary.cjs +62 -0
- package/src/main/__tests__/agentModelResolve.test.cjs +66 -0
- package/src/main/__tests__/health-delegation-chain.test.cjs +2 -1
- package/src/main/__tests__/prdAgentType.test.cjs +103 -0
- package/src/main/__tests__/prdCreate.test.cjs +138 -0
- package/src/main/__tests__/prdFrontmatterAgentType.test.cjs +117 -0
- package/src/main/__tests__/prdFrontmatterQuietMachine.test.cjs +108 -0
- package/src/main/__tests__/projectHomeAdminRoutes.test.cjs +485 -0
- package/src/main/__tests__/projectPages.test.cjs +73 -1
- package/src/main/__tests__/rcaReport.test.cjs +54 -0
- package/src/main/__tests__/runVerify.test.cjs +94 -0
- package/src/main/__tests__/scheduler-autofix-select.test.cjs +43 -0
- package/src/main/__tests__/scheduler-bash-timeout-env.test.cjs +103 -0
- package/src/main/__tests__/scheduler-effective-concurrency.test.cjs +10 -0
- package/src/main/__tests__/scheduler-foreign-wip-manifest.test.cjs +78 -0
- package/src/main/__tests__/scheduler-inplace-salvage.test.cjs +242 -0
- package/src/main/__tests__/scheduler-investigation-prompt.test.cjs +31 -0
- package/src/main/__tests__/scheduler-launch-failure.test.cjs +201 -0
- package/src/main/__tests__/scheduler-leftover-fields.test.cjs +52 -0
- package/src/main/__tests__/scheduler-looks-done.test.cjs +241 -0
- package/src/main/__tests__/scheduler-prd-persona-spawn.test.cjs +135 -0
- package/src/main/__tests__/scheduler-quiet-machine-lease.test.cjs +222 -0
- package/src/main/__tests__/scheduler-reap-dead-running-jobs.test.cjs +147 -0
- package/src/main/__tests__/scheduler-shared-tree-guard.test.cjs +212 -0
- package/src/main/__tests__/scheduler-worktree-cap-defer.test.cjs +194 -0
- package/src/main/__tests__/seedAgentPersonas.test.cjs +75 -14
- package/src/main/config.cjs +4 -1
- package/src/main/index.cjs +16 -4
- package/src/main/ipcSchemas.cjs +53 -0
- package/src/main/lib/__tests__/childWithLog.test.cjs +78 -0
- package/src/main/lib/__tests__/delegationReadiness.test.cjs +197 -2
- package/src/main/lib/__tests__/epicWorktreeMint.test.cjs +4 -2
- package/src/main/lib/__tests__/fixChainDepth.test.cjs +40 -0
- package/src/main/lib/__tests__/gitWorktree.test.cjs +277 -4
- package/src/main/lib/__tests__/gitWorktreeSalvageDelta.test.cjs +153 -0
- package/src/main/lib/__tests__/jobWorktree.test.cjs +5 -3
- package/src/main/lib/__tests__/landedSinceRun.test.cjs +73 -0
- package/src/main/lib/__tests__/launchFailure.test.cjs +220 -0
- package/src/main/lib/__tests__/mcpToolCatalog.test.cjs +1 -0
- package/src/main/lib/__tests__/opsOwnership.test.cjs +7 -0
- package/src/main/lib/__tests__/prdDeclaredPaths.test.cjs +82 -0
- package/src/main/lib/__tests__/queueHealth.test.cjs +58 -0
- package/src/main/lib/__tests__/quietMachineLease.test.cjs +39 -0
- package/src/main/lib/__tests__/reaperHelpers.test.cjs +22 -1
- package/src/main/lib/__tests__/schedulerBatchLaunchHold.test.cjs +125 -0
- package/src/main/lib/__tests__/schedulerBatchQuietMachine.test.cjs +109 -0
- package/src/main/lib/__tests__/schedulerMcpServerHeadlessRefusal.test.cjs +71 -0
- package/src/main/lib/__tests__/schedulerMcpServerProjectHome.test.cjs +350 -0
- package/src/main/lib/agentModelResolve.cjs +58 -0
- package/src/main/lib/childWithLog.cjs +40 -5
- package/src/main/lib/claudeBin.cjs +54 -1
- package/src/main/lib/definitionOfDone.cjs +3 -2
- package/src/main/lib/delegationReadiness.cjs +115 -9
- package/src/main/lib/epicWorktreeMint.cjs +5 -2
- package/src/main/lib/fixChainDepth.cjs +45 -0
- package/src/main/lib/gitWorktree.cjs +464 -19
- package/src/main/lib/jobWorktree.cjs +1 -0
- package/src/main/lib/landedSinceRun.cjs +55 -0
- package/src/main/lib/launchFailure.cjs +357 -0
- package/src/main/lib/mcpToolCatalog.cjs +87 -2
- package/src/main/lib/opsOwnership.cjs +12 -0
- package/src/main/lib/prdAgentType.cjs +84 -0
- package/src/main/lib/prdCreate.cjs +57 -1
- package/src/main/lib/prdDeclaredPaths.cjs +70 -0
- package/src/main/lib/prdFrontmatter.cjs +17 -3
- package/src/main/lib/projectHomeAdminRoutes.cjs +402 -0
- package/src/main/lib/projectPageSummarySchema.cjs +181 -0
- package/src/main/lib/queueHealth.cjs +38 -0
- package/src/main/lib/queueStore.cjs +9 -2
- package/src/main/lib/quietMachineLease.cjs +48 -0
- package/src/main/lib/rcaReport.cjs +53 -3
- package/src/main/lib/reaperHelpers.cjs +18 -1
- package/src/main/lib/scheduleJobSchema.cjs +31 -0
- package/src/main/lib/scheduleJobTransitions.cjs +6 -2
- package/src/main/lib/schedulerBatch.cjs +133 -29
- package/src/main/lib/schedulerConfig.cjs +19 -0
- package/src/main/projectPages.cjs +160 -2
- package/src/main/runVerify.cjs +50 -9
- package/src/main/scheduler/prdParser.cjs +18 -1
- package/src/main/scheduler.cjs +1371 -97
- package/src/main/seedAgentPersonas.cjs +62 -21
- package/src/main/templates/project-pages-catalog.json +741 -0
- package/src/main/templates/project-pages-pipeline.md +417 -0
- package/src/preload/api.d.ts +126 -6
- package/src/preload/index.cjs +8 -0
- package/src/seed/agents/project-home-builder.md +59 -0
- package/dist/assets/SaveBar-Qvc4Ek-H.js +0 -1
- package/dist/assets/Scheduler-BmYJvNzK.js +0 -14
- package/dist/assets/TagLibrary-DYJGAKZu.js +0 -1
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* landedSinceRun.cjs — widened, path-scoped commit evidence for the reverify
|
|
5
|
+
* self-heal pass (PRD 1102).
|
|
6
|
+
*
|
|
7
|
+
* committedInWindow (scheduler.cjs) only sees commits inside
|
|
8
|
+
* [startedAt, finishedAt+60s] — a commit that lands later (a retry, a
|
|
9
|
+
* sibling run, a human) is invisible to it. landedSinceRun has no upper
|
|
10
|
+
* bound, but narrows the OTHER way that committedInWindow is dangerously
|
|
11
|
+
* broad: it is scoped to paths the PRD itself declares, so an unrelated
|
|
12
|
+
* commit elsewhere in the repo is not credited to this job (see
|
|
13
|
+
* scheduler.cjs's healRefusalReason for why repo-wide, unscoped evidence is
|
|
14
|
+
* not attribution).
|
|
15
|
+
*
|
|
16
|
+
* Pure git wrapper — no fetch, no scheduler state. Callers that need remote
|
|
17
|
+
* commits visible (e.g. a job that committed in a since-removed worktree)
|
|
18
|
+
* must call scheduler.cjs's fetchAllRefs(cwd) first, same as
|
|
19
|
+
* committedInWindow's own callers do.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
const { execFile } = require('node:child_process');
|
|
23
|
+
|
|
24
|
+
const LANDED_SINCE_RUN_TIMEOUT_MS = 10_000;
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Commits in `cwd` since `sinceIso` (no upper bound) that touch any of
|
|
28
|
+
* `paths`. Never throws — git-unavailable, a non-repo cwd, or an empty
|
|
29
|
+
* `paths` list all resolve to `[]` rather than fabricating evidence.
|
|
30
|
+
*
|
|
31
|
+
* @param {string} cwd
|
|
32
|
+
* @param {string} sinceIso
|
|
33
|
+
* @param {string[]} paths
|
|
34
|
+
* @param {{ timeoutMs?: number }} [opts]
|
|
35
|
+
* @returns {Promise<string[]>} full commit SHAs, newest first
|
|
36
|
+
*/
|
|
37
|
+
function landedSinceRun(cwd, sinceIso, paths, { timeoutMs = LANDED_SINCE_RUN_TIMEOUT_MS } = {}) {
|
|
38
|
+
return new Promise((resolve) => {
|
|
39
|
+
if (!cwd || !sinceIso || !Array.isArray(paths) || paths.length === 0) {
|
|
40
|
+
resolve([]);
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
execFile(
|
|
44
|
+
'git',
|
|
45
|
+
['-C', cwd, 'log', '--all', `--since=${sinceIso}`, '--format=%H', '--', ...paths],
|
|
46
|
+
{ timeout: timeoutMs, windowsHide: true },
|
|
47
|
+
(err, stdout) => {
|
|
48
|
+
if (err) { resolve([]); return; }
|
|
49
|
+
resolve(String(stdout || '').trim().split('\n').filter(Boolean));
|
|
50
|
+
},
|
|
51
|
+
);
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
module.exports = { landedSinceRun, LANDED_SINCE_RUN_TIMEOUT_MS };
|
|
@@ -0,0 +1,357 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* launchFailure.cjs — detects a headless `claude -p` run that NEVER RAN, and
|
|
5
|
+
* models the per-persona launch circuit breaker the scheduler routes on.
|
|
6
|
+
*
|
|
7
|
+
* Incident (GitHub issue #11, 2026-09-02, macOS): the installed Claude CLI
|
|
8
|
+
* sent `thinking.type.enabled` to a model that only accepts
|
|
9
|
+
* `thinking.type.adaptive`. The API answered HTTP 400 on the very first
|
|
10
|
+
* request, so every job did zero turns, spent zero output tokens, wrote zero
|
|
11
|
+
* files, exited 1 in ~25 s — and was recorded as `failed` with `error: null`,
|
|
12
|
+
* indistinguishable from a real implementation failure. The auto-fix
|
|
13
|
+
* investigation probe then launched with the same CLI and died the same way.
|
|
14
|
+
* 12 of 41 transcripts in one project over a month were this signature.
|
|
15
|
+
*
|
|
16
|
+
* Two facts this module makes first-class:
|
|
17
|
+
*
|
|
18
|
+
* 1. A NON-RUN is not a failure of the PRD. `classifyLaunchFailure` is
|
|
19
|
+
* deliberately narrow — it only fires when the transcript's `result`
|
|
20
|
+
* event shows no real turn (num_turns ≤ 1 AND output_tokens = 0) AND the
|
|
21
|
+
* result text carries the CLI's literal `API Error:` prefix. Anything
|
|
22
|
+
* that did a turn, or failed without the API marker, is somebody else's
|
|
23
|
+
* classification (rate-limit, network, transient, verifier).
|
|
24
|
+
*
|
|
25
|
+
* 2. The environment is broken, not the job — so the scheduler must stop
|
|
26
|
+
* re-dispatching identical launches (each one is a wasted 25 s + a
|
|
27
|
+
* misleading `failed` row + a doomed investigation) while still
|
|
28
|
+
* self-healing the moment the environment is fixed. That is a circuit
|
|
29
|
+
* breaker keyed by the launch persona (`agentType` → model): closed
|
|
30
|
+
* (normal) → open (blocked, exponential backoff) → half-open (exactly one
|
|
31
|
+
* probe job goes through) → closed again on a real turn. A CLI version
|
|
32
|
+
* change (the actual fix for the incident: `claude update`) short-circuits
|
|
33
|
+
* the backoff so the queue resumes on the next tick, not the next hour.
|
|
34
|
+
*
|
|
35
|
+
* Pure and Electron-free: every function here takes plain values (a parsed
|
|
36
|
+
* result event, a block record, `now`) so the whole state machine is
|
|
37
|
+
* unit-testable without a spawn. scheduler.cjs owns the I/O around it.
|
|
38
|
+
*/
|
|
39
|
+
|
|
40
|
+
const fs = require('node:fs');
|
|
41
|
+
const path = require('node:path');
|
|
42
|
+
const { readTail } = require('./fileTail.cjs');
|
|
43
|
+
|
|
44
|
+
const LAUNCH_FAILURE_KINDS = Object.freeze({
|
|
45
|
+
/** HTTP 400 naming a thinking/effort/config parameter the model rejects — the issue-#11 signature. */
|
|
46
|
+
MODEL_CONFIG_REJECTED: 'model_config_rejected',
|
|
47
|
+
/** Any other HTTP 400 on the first request (malformed request body, unsupported flag combo). */
|
|
48
|
+
BAD_REQUEST: 'bad_request',
|
|
49
|
+
/** HTTP 401/403 — the CLI's credentials are missing, expired, or lack access to the model. */
|
|
50
|
+
AUTH_FAILED: 'auth_failed',
|
|
51
|
+
/** HTTP 404 that names the model — the pinned `--model` does not exist for this account/CLI. */
|
|
52
|
+
MODEL_NOT_FOUND: 'model_not_found',
|
|
53
|
+
/** HTTP 5xx / 529 / "Overloaded" — the API itself is unavailable right now. */
|
|
54
|
+
API_OVERLOADED: 'api_overloaded',
|
|
55
|
+
/** Any other first-request API error. */
|
|
56
|
+
API_ERROR: 'api_error',
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
/** Tail bytes scanned for the `result` event — same budget classifyRunOutcome uses. */
|
|
60
|
+
const RESULT_TAIL_BYTES = 65536;
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Parse the LAST `{"type":"result",...}` stream-json event out of a log tail.
|
|
64
|
+
* Returns a flat, typed summary or null when no result event is present
|
|
65
|
+
* (the process died before the harness could emit one — that is
|
|
66
|
+
* reaperHelpers' `no_result`, not a launch failure).
|
|
67
|
+
*/
|
|
68
|
+
function parseResultEvent(text) {
|
|
69
|
+
if (!text) return null;
|
|
70
|
+
let last = null;
|
|
71
|
+
for (const line of String(text).split('\n')) {
|
|
72
|
+
const t = line.trim();
|
|
73
|
+
if (!t.startsWith('{') || !t.includes('"type":"result"')) continue;
|
|
74
|
+
try {
|
|
75
|
+
const obj = JSON.parse(t);
|
|
76
|
+
if (obj && obj.type === 'result') last = obj;
|
|
77
|
+
} catch { /* partial line at the tail boundary */ }
|
|
78
|
+
}
|
|
79
|
+
if (!last) return null;
|
|
80
|
+
const usage = last.usage && typeof last.usage === 'object' ? last.usage : {};
|
|
81
|
+
const num = (v) => (typeof v === 'number' && Number.isFinite(v) ? v : null);
|
|
82
|
+
return {
|
|
83
|
+
subtype: typeof last.subtype === 'string' ? last.subtype : '',
|
|
84
|
+
isError: last.is_error === true,
|
|
85
|
+
numTurns: num(last.num_turns),
|
|
86
|
+
outputTokens: num(usage.output_tokens),
|
|
87
|
+
inputTokens: num(usage.input_tokens),
|
|
88
|
+
apiErrorStatus: num(last.api_error_status),
|
|
89
|
+
totalCostUsd: num(last.total_cost_usd),
|
|
90
|
+
durationMs: num(last.duration_ms),
|
|
91
|
+
terminalReason: typeof last.terminal_reason === 'string' ? last.terminal_reason : null,
|
|
92
|
+
resultText: typeof last.result === 'string' ? last.result : '',
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function readResultEvent(logPath) {
|
|
97
|
+
try {
|
|
98
|
+
return parseResultEvent(readTail(logPath, RESULT_TAIL_BYTES));
|
|
99
|
+
} catch {
|
|
100
|
+
return null;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Pull the human-readable message out of the CLI's `API Error: <status> <json>`
|
|
106
|
+
* text. The body nests unpredictably (`{"error":{"message":...}}`, or
|
|
107
|
+
* `{"detail":{"error":"<json string with message>"}}` as in issue #11), so
|
|
108
|
+
* this walks any `message`/`error` chain it can parse and falls back to the
|
|
109
|
+
* raw text, bounded.
|
|
110
|
+
*/
|
|
111
|
+
function extractApiMessage(text) {
|
|
112
|
+
const raw = String(text || '').trim();
|
|
113
|
+
const jsonStart = raw.indexOf('{');
|
|
114
|
+
if (jsonStart >= 0) {
|
|
115
|
+
let node;
|
|
116
|
+
try { node = JSON.parse(raw.slice(jsonStart)); } catch { node = null; }
|
|
117
|
+
let depth = 0;
|
|
118
|
+
while (node && depth < 6) {
|
|
119
|
+
depth += 1;
|
|
120
|
+
if (typeof node === 'string') {
|
|
121
|
+
const s = node.trim();
|
|
122
|
+
if (s.startsWith('{')) {
|
|
123
|
+
try { node = JSON.parse(s); continue; } catch { /* not JSON — it's the message */ }
|
|
124
|
+
}
|
|
125
|
+
return s.slice(0, 400);
|
|
126
|
+
}
|
|
127
|
+
if (typeof node !== 'object') break;
|
|
128
|
+
if (typeof node.message === 'string') return node.message.slice(0, 400);
|
|
129
|
+
node = node.error ?? node.detail ?? null;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
return raw.slice(0, 400);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* classifyLaunchFailure(result) → null | { kind, httpStatus, message }
|
|
137
|
+
*
|
|
138
|
+
* `result` is parseResultEvent()'s output. Returns null for every run that
|
|
139
|
+
* did real work (or failed for a reason that is not a first-request API
|
|
140
|
+
* rejection) — the narrowness is the point; see the module header.
|
|
141
|
+
* HTTP 429 is excluded: the rate-limit pause path owns it.
|
|
142
|
+
*/
|
|
143
|
+
function classifyLaunchFailure(result) {
|
|
144
|
+
if (!result) return null;
|
|
145
|
+
const numTurns = result.numTurns ?? 0;
|
|
146
|
+
const outputTokens = result.outputTokens ?? 0;
|
|
147
|
+
if (numTurns > 1 || outputTokens > 0) return null;
|
|
148
|
+
const text = result.resultText || '';
|
|
149
|
+
const marker = /API Error:?\s*(\d{3})?/i.exec(text);
|
|
150
|
+
if (!marker && !(result.isError && result.apiErrorStatus)) return null;
|
|
151
|
+
const httpStatus = (marker && marker[1] ? Number(marker[1]) : null) ?? result.apiErrorStatus ?? null;
|
|
152
|
+
if (httpStatus === 429) return null;
|
|
153
|
+
const message = extractApiMessage(text.replace(/^.*?API Error:?\s*(\d{3})?\s*/i, '')) || text.slice(0, 400);
|
|
154
|
+
let kind;
|
|
155
|
+
if (httpStatus === 400) {
|
|
156
|
+
kind = /thinking|not supported for this model|output_config|effort/i.test(text)
|
|
157
|
+
? LAUNCH_FAILURE_KINDS.MODEL_CONFIG_REJECTED
|
|
158
|
+
: LAUNCH_FAILURE_KINDS.BAD_REQUEST;
|
|
159
|
+
} else if (httpStatus === 401 || httpStatus === 403) {
|
|
160
|
+
kind = LAUNCH_FAILURE_KINDS.AUTH_FAILED;
|
|
161
|
+
} else if (httpStatus === 404 && /model/i.test(text)) {
|
|
162
|
+
kind = LAUNCH_FAILURE_KINDS.MODEL_NOT_FOUND;
|
|
163
|
+
} else if ((httpStatus !== null && httpStatus >= 500) || /overloaded/i.test(text)) {
|
|
164
|
+
kind = LAUNCH_FAILURE_KINDS.API_OVERLOADED;
|
|
165
|
+
} else {
|
|
166
|
+
kind = LAUNCH_FAILURE_KINDS.API_ERROR;
|
|
167
|
+
}
|
|
168
|
+
return { kind, httpStatus, message };
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** Did this run get at least one real model turn? (The half-open probe's "close the breaker" evidence.) */
|
|
172
|
+
function resultShowsRealTurn(result) {
|
|
173
|
+
if (!result) return false;
|
|
174
|
+
return (result.numTurns ?? 0) > 1 || (result.outputTokens ?? 0) > 0;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// ─── Circuit breaker ────────────────────────────────────────────────────────
|
|
178
|
+
|
|
179
|
+
/** After this many consecutive failed probes the block stays open until the CLI version changes or a human resets it. */
|
|
180
|
+
const LAUNCH_BLOCK_MAX_ATTEMPTS = 8;
|
|
181
|
+
/** A probe that has not reported back in this long is presumed dead; the next tick may probe again. */
|
|
182
|
+
const LAUNCH_PROBE_STALE_MS = 30 * 60_000;
|
|
183
|
+
|
|
184
|
+
const BACKOFF_BASE_MS = {
|
|
185
|
+
[LAUNCH_FAILURE_KINDS.MODEL_CONFIG_REJECTED]: 5 * 60_000,
|
|
186
|
+
[LAUNCH_FAILURE_KINDS.BAD_REQUEST]: 5 * 60_000,
|
|
187
|
+
[LAUNCH_FAILURE_KINDS.AUTH_FAILED]: 5 * 60_000,
|
|
188
|
+
[LAUNCH_FAILURE_KINDS.MODEL_NOT_FOUND]: 10 * 60_000,
|
|
189
|
+
[LAUNCH_FAILURE_KINDS.API_OVERLOADED]: 60_000,
|
|
190
|
+
[LAUNCH_FAILURE_KINDS.API_ERROR]: 2 * 60_000,
|
|
191
|
+
};
|
|
192
|
+
const BACKOFF_CAP_MS = 60 * 60_000;
|
|
193
|
+
|
|
194
|
+
/** Exponential backoff for the Nth consecutive failure (attempts ≥ 1), capped at one hour. */
|
|
195
|
+
function backoffMsFor(kind, attempts) {
|
|
196
|
+
const base = BACKOFF_BASE_MS[kind] ?? BACKOFF_BASE_MS[LAUNCH_FAILURE_KINDS.API_ERROR];
|
|
197
|
+
const n = Math.max(0, (attempts ?? 1) - 1);
|
|
198
|
+
return Math.min(BACKOFF_CAP_MS, base * 2 ** n);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Environment the scheduler applies to a launch as a DEGRADED-MODE
|
|
203
|
+
* mitigation for a kind, or null when there is none. For the issue-#11
|
|
204
|
+
* signature the CLI's `MAX_THINKING_TOKENS=0` switches extended thinking off
|
|
205
|
+
* entirely, so an older CLI stops sending the rejected `thinking` block at
|
|
206
|
+
* all — the run proceeds without thinking rather than not at all. Harmless
|
|
207
|
+
* if the CLI ignores it (the probe simply fails again and the backoff holds).
|
|
208
|
+
*/
|
|
209
|
+
function mitigationEnvFor(kind) {
|
|
210
|
+
if (kind === LAUNCH_FAILURE_KINDS.MODEL_CONFIG_REJECTED) return { MAX_THINKING_TOKENS: '0' };
|
|
211
|
+
return null;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/** Circuit-breaker key: the launch persona, because `agentType` is what selects the `--model`. */
|
|
215
|
+
function launchBlockKeyFor(job) {
|
|
216
|
+
return (job && typeof job.agentType === 'string' && job.agentType) || 'default';
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/** Operator-facing explanation + the one action that clears the condition. */
|
|
220
|
+
function launchFailureHint(kind, { claudeVersion, mitigationInForce = false } = {}) {
|
|
221
|
+
const ver = claudeVersion ? `installed Claude CLI ${claudeVersion}` : 'installed Claude CLI';
|
|
222
|
+
switch (kind) {
|
|
223
|
+
case LAUNCH_FAILURE_KINDS.MODEL_CONFIG_REJECTED:
|
|
224
|
+
return mitigationInForce
|
|
225
|
+
? `The ${ver} sends a thinking parameter this model rejects, and disabling thinking (MAX_THINKING_TOKENS=0) did not get past it. Update the CLI (\`claude update\` or \`npm i -g @anthropic-ai/claude-code@latest\`); the queue resumes automatically when the version changes.`
|
|
226
|
+
: `The ${ver} sends a thinking parameter this model rejects (HTTP 400 on the first request — no work was attempted). Update the CLI (\`claude update\` or \`npm i -g @anthropic-ai/claude-code@latest\`); until then jobs re-probe with thinking disabled, and the queue resumes automatically when the version changes.`;
|
|
227
|
+
case LAUNCH_FAILURE_KINDS.AUTH_FAILED:
|
|
228
|
+
return `The API rejected the CLI's credentials (HTTP 401/403) before any work started. Run \`claude login\` (or check the model is enabled for this account), then press Retry now.`;
|
|
229
|
+
case LAUNCH_FAILURE_KINDS.MODEL_NOT_FOUND:
|
|
230
|
+
return `The pinned --model does not exist for the ${ver} / this account (HTTP 404). Fix the persona's \`model:\` in the Agent Library or update the CLI, then press Retry now.`;
|
|
231
|
+
case LAUNCH_FAILURE_KINDS.API_OVERLOADED:
|
|
232
|
+
return 'The API is overloaded or unavailable (HTTP 5xx/529). Nothing is wrong with the PRD; the scheduler re-probes with backoff and resumes on its own.';
|
|
233
|
+
case LAUNCH_FAILURE_KINDS.BAD_REQUEST:
|
|
234
|
+
return `The API rejected the launch request (HTTP 400) before any work started. Check the ${ver} against the pinned model, then press Retry now.`;
|
|
235
|
+
default:
|
|
236
|
+
return 'The first API request of the run failed before any work started. The scheduler re-probes with backoff; press Retry now to probe immediately.';
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* armLaunchBlock(prev, failure) → block
|
|
242
|
+
*
|
|
243
|
+
* Opens (or re-opens with a longer backoff) the breaker for one key after a
|
|
244
|
+
* launch failure. `prev` is the existing block for the key or null; a
|
|
245
|
+
* different `kind` than before restarts the attempt count, the same kind
|
|
246
|
+
* escalates it. Beyond LAUNCH_BLOCK_MAX_ATTEMPTS `until` becomes null:
|
|
247
|
+
* blocked indefinitely — only a CLI version change or a human Retry clears
|
|
248
|
+
* it, and the UI says so.
|
|
249
|
+
*/
|
|
250
|
+
function armLaunchBlock(prev, { kind, httpStatus, message, now, claudeVersion, slug, runId, mitigationApplied = false }) {
|
|
251
|
+
const sameKind = prev && prev.kind === kind;
|
|
252
|
+
const attempts = sameKind ? (prev.attempts ?? 0) + 1 : 1;
|
|
253
|
+
const exhausted = attempts >= LAUNCH_BLOCK_MAX_ATTEMPTS;
|
|
254
|
+
const mitigationEnv = mitigationEnvFor(kind);
|
|
255
|
+
return {
|
|
256
|
+
kind,
|
|
257
|
+
httpStatus: httpStatus ?? null,
|
|
258
|
+
message: String(message || '').slice(0, 400),
|
|
259
|
+
hint: launchFailureHint(kind, { claudeVersion, mitigationInForce: mitigationApplied }),
|
|
260
|
+
since: sameKind && prev.since ? prev.since : new Date(now).toISOString(),
|
|
261
|
+
lastAt: new Date(now).toISOString(),
|
|
262
|
+
until: exhausted ? null : new Date(now + backoffMsFor(kind, attempts)).toISOString(),
|
|
263
|
+
attempts,
|
|
264
|
+
exhausted,
|
|
265
|
+
claudeVersion: claudeVersion ?? null,
|
|
266
|
+
lastSlug: slug ?? null,
|
|
267
|
+
lastRunId: runId ?? null,
|
|
268
|
+
mitigationEnv,
|
|
269
|
+
mitigationApplied,
|
|
270
|
+
probing: null,
|
|
271
|
+
};
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* evaluateLaunchGate(block, { now, claudeVersion }) →
|
|
276
|
+
* { state: 'open' | 'blocked' | 'probe', reason }
|
|
277
|
+
*
|
|
278
|
+
* 'open' — no block, or the CLI version changed since it was armed (the
|
|
279
|
+
* caller should drop the block: the environment was replaced).
|
|
280
|
+
* 'blocked' — inside the backoff window, exhausted, or a probe is already in
|
|
281
|
+
* flight. `reason` is the row-level hold text.
|
|
282
|
+
* 'probe' — backoff elapsed: let exactly ONE job through as the probe.
|
|
283
|
+
*/
|
|
284
|
+
function evaluateLaunchGate(block, { now, claudeVersion } = {}) {
|
|
285
|
+
if (!block) return { state: 'open', reason: null };
|
|
286
|
+
if (claudeVersion && block.claudeVersion && claudeVersion !== block.claudeVersion) {
|
|
287
|
+
return { state: 'open', reason: `cli-version-changed (${block.claudeVersion} → ${claudeVersion})` };
|
|
288
|
+
}
|
|
289
|
+
const t = typeof now === 'number' ? now : Date.now();
|
|
290
|
+
if (block.probing && block.probing.at) {
|
|
291
|
+
const age = t - Date.parse(block.probing.at);
|
|
292
|
+
if (Number.isFinite(age) && age >= 0 && age < LAUNCH_PROBE_STALE_MS) {
|
|
293
|
+
return { state: 'blocked', reason: `launch blocked (${block.kind}) — probe ${block.probing.slug} in flight` };
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
if (block.until === null || block.until === undefined) {
|
|
297
|
+
return { state: 'blocked', reason: `launch blocked (${block.kind}) after ${block.attempts} failed probe(s) — ${block.hint}` };
|
|
298
|
+
}
|
|
299
|
+
const until = Date.parse(block.until);
|
|
300
|
+
if (Number.isFinite(until) && t < until) {
|
|
301
|
+
const mins = Math.max(1, Math.round((until - t) / 60_000));
|
|
302
|
+
return { state: 'blocked', reason: `launch blocked (${block.kind}) — re-probe in ${mins} min. ${block.hint}` };
|
|
303
|
+
}
|
|
304
|
+
return { state: 'probe', reason: `launch probe (${block.kind}) — attempt ${(block.attempts ?? 0) + 1}` };
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/**
|
|
308
|
+
* Terminal-reason taxonomy for a finalized job row (issue #11 list A2). A
|
|
309
|
+
* closed set so operators never have to open a transcript to tell a
|
|
310
|
+
* non-start from an implementation failure from a verifier downgrade.
|
|
311
|
+
*/
|
|
312
|
+
function deriveTerminalReason({ effectiveStatus, exitCode, verifyResult, sigtermOverride, worktreeIntegrationFailure }) {
|
|
313
|
+
if (worktreeIntegrationFailure) return 'worktree_integration_failed';
|
|
314
|
+
if (effectiveStatus === 'completed') return 'completed';
|
|
315
|
+
if (sigtermOverride) return 'signal_kill_with_commit';
|
|
316
|
+
if (exitCode === 143 || exitCode === 137) return 'signal_kill';
|
|
317
|
+
if (typeof exitCode === 'number' && exitCode !== 0) return `impl_failed:exit_${exitCode}`;
|
|
318
|
+
if (effectiveStatus === 'needs_review' && verifyResult && verifyResult.verdict) return `verifier:${verifyResult.verdict}`;
|
|
319
|
+
return effectiveStatus || 'unknown';
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/**
|
|
323
|
+
* Write the per-run `<slug>.outcome.json` sidecar (issue #11 list B5): the
|
|
324
|
+
* handful of numbers that make fleet health computable without parsing
|
|
325
|
+
* transcripts. Best-effort, never throws.
|
|
326
|
+
*/
|
|
327
|
+
function writeOutcomeSidecar(runDir, slug, outcome) {
|
|
328
|
+
if (!runDir || !slug) return null;
|
|
329
|
+
const p = path.join(runDir, `${slug}.outcome.json`);
|
|
330
|
+
try {
|
|
331
|
+
const tmp = `${p}.tmp`;
|
|
332
|
+
fs.writeFileSync(tmp, JSON.stringify({ slug, writtenAt: new Date().toISOString(), ...outcome }, null, 2));
|
|
333
|
+
fs.renameSync(tmp, p);
|
|
334
|
+
return p;
|
|
335
|
+
} catch {
|
|
336
|
+
return null;
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
module.exports = {
|
|
341
|
+
LAUNCH_FAILURE_KINDS,
|
|
342
|
+
LAUNCH_BLOCK_MAX_ATTEMPTS,
|
|
343
|
+
LAUNCH_PROBE_STALE_MS,
|
|
344
|
+
parseResultEvent,
|
|
345
|
+
readResultEvent,
|
|
346
|
+
extractApiMessage,
|
|
347
|
+
classifyLaunchFailure,
|
|
348
|
+
resultShowsRealTurn,
|
|
349
|
+
backoffMsFor,
|
|
350
|
+
mitigationEnvFor,
|
|
351
|
+
launchBlockKeyFor,
|
|
352
|
+
launchFailureHint,
|
|
353
|
+
armLaunchBlock,
|
|
354
|
+
evaluateLaunchGate,
|
|
355
|
+
deriveTerminalReason,
|
|
356
|
+
writeOutcomeSidecar,
|
|
357
|
+
};
|
|
@@ -23,7 +23,7 @@ const { z } = require('zod');
|
|
|
23
23
|
|
|
24
24
|
const CatalogEntrySchema = z.object({
|
|
25
25
|
name: z.string().min(1),
|
|
26
|
-
group: z.enum(['scheduler', 'chat', 'feedback', 'help']),
|
|
26
|
+
group: z.enum(['scheduler', 'chat', 'feedback', 'help', 'project-home']),
|
|
27
27
|
purpose: z.string().min(1),
|
|
28
28
|
whenToUse: z.string().min(1),
|
|
29
29
|
whenNotToUse: z.string().min(1),
|
|
@@ -71,7 +71,11 @@ const MCP_TOOL_CATALOG = [
|
|
|
71
71
|
+ '`cwd` is OPTIONAL when called from inside an Epic session (chat or terminal, '
|
|
72
72
|
+ 'including from inside that Epic\'s own git worktree pwd): the server resolves '
|
|
73
73
|
+ 'the real project from the calling session\'s sourcePromptId/originClaudeSessionId, '
|
|
74
|
-
+ 'never from a worktree\'s own possibly-stale active-index.json snapshot.'
|
|
74
|
+
+ 'never from a worktree\'s own possibly-stale active-index.json snapshot. '
|
|
75
|
+
+ '`tag` and `agentType` are two DIFFERENT, independent fields — `tag` is WHAT KIND '
|
|
76
|
+
+ 'of work this PRD is (feature/bug/build/...); `agentType` is WHO executes it (a '
|
|
77
|
+
+ 'persona name, e.g. "dev-lead"). `agentType` defaults to "dev-lead" when omitted, '
|
|
78
|
+
+ 'and is rejected at write time if it does not name a real persona file.',
|
|
75
79
|
whenToUse: 'Use whenever new work should be queued into an already-approved Epic — this is the /develop path.',
|
|
76
80
|
whenNotToUse: 'TWO DISTINCT FAILURE MODES if this tool is not usable — do not conflate them: '
|
|
77
81
|
+ '(a) this tool call is PRESENT in your tool list but ERRORS as app-not-running / admin '
|
|
@@ -213,6 +217,68 @@ const MCP_TOOL_CATALOG = [
|
|
|
213
217
|
+ 'the other project has "been fixed" or "is working on it". Report only that the proposal was '
|
|
214
218
|
+ 'delivered.',
|
|
215
219
|
},
|
|
220
|
+
{
|
|
221
|
+
name: 'project_home_get_contract',
|
|
222
|
+
group: 'project-home',
|
|
223
|
+
purpose: "Fetch the self-sufficient contract for generating this project's Project Home pages via the "
|
|
224
|
+
+ "session-manager app's admin API — the protocol steps, the ProjectPageSummary/ProjectPagePicks JSON "
|
|
225
|
+
+ 'schemas, the component catalog (all 5 lenses: home, marketing, feature, architecture, brief), the '
|
|
226
|
+
+ 'absolute output paths, and the full pipeline spec text. Nothing in the response requires reading '
|
|
227
|
+
+ "session-manager's own repo — this is what makes generation work on a machine with only the npm "
|
|
228
|
+
+ 'package installed.',
|
|
229
|
+
whenToUse: 'Call this FIRST, before composing any ProjectPageSummary content or picking variants, whenever '
|
|
230
|
+
+ 'a project-home-builder session starts generating Project Home pages for a project.',
|
|
231
|
+
whenNotToUse: 'Do not begin composing a ProjectPageSummary or choosing variants before calling this — it '
|
|
232
|
+
+ 'must be the FIRST call a project-home-builder session makes; every other project_home_* tool assumes '
|
|
233
|
+
+ "you already have this response's schema/catalog/paths in hand.",
|
|
234
|
+
exampleArgs: { cwd: '/home/bilko/Projects/session-manager' },
|
|
235
|
+
notes: "cwd is optional — defaults to the calling session's own project root (SM_PROJECT_ROOT or process.cwd()) when omitted.",
|
|
236
|
+
},
|
|
237
|
+
{
|
|
238
|
+
name: 'project_home_validate_summary',
|
|
239
|
+
group: 'project-home',
|
|
240
|
+
purpose: 'Validate a composed ProjectPageSummary against the schema from project_home_get_contract via the '
|
|
241
|
+
+ "session-manager app's admin API, without writing anything to disk.",
|
|
242
|
+
whenToUse: 'Use after composing (or editing) a ProjectPageSummary and before calling project_home_render — '
|
|
243
|
+
+ 'fix every returned {field, message} error and re-validate until valid:true.',
|
|
244
|
+
whenNotToUse: 'Do not skip straight to project_home_render on an unvalidated summary — render does '
|
|
245
|
+
+ 're-validate server-side and rejects with no writes on failure, but catching errors here first saves a '
|
|
246
|
+
+ 'round trip and gives per-field detail sooner.',
|
|
247
|
+
exampleArgs: { cwd: '/home/bilko/Projects/session-manager', summary: { title: 'Example Project' } },
|
|
248
|
+
notes: "cwd is optional — defaults to the calling session's own project root (SM_PROJECT_ROOT or process.cwd()) when omitted.",
|
|
249
|
+
},
|
|
250
|
+
{
|
|
251
|
+
name: 'project_home_render',
|
|
252
|
+
group: 'project-home',
|
|
253
|
+
purpose: 'Render and write all 5 Project Home lenses (home, marketing, feature, architecture, brief) plus '
|
|
254
|
+
+ "summary.json/picks.json/manifest.json via the session-manager app's admin API — the only write path "
|
|
255
|
+
+ 'for Project Home generation.',
|
|
256
|
+
whenToUse: 'Use once project_home_validate_summary reports valid:true and a variant has been picked for '
|
|
257
|
+
+ "every lens/slot in the contract's catalog.",
|
|
258
|
+
whenNotToUse: 'Must NOT be used to fabricate summary content — every field of the summary passed here must '
|
|
259
|
+
+ 'trace to something concrete about the real project (an Epic goal, a source file/dir, a convention, a '
|
|
260
|
+
+ 'git log entry). This tool re-validates the summary server-side and rejects with no writes on schema '
|
|
261
|
+
+ 'failure, but it only checks shape, never truthfulness.',
|
|
262
|
+
exampleArgs: {
|
|
263
|
+
cwd: '/home/bilko/Projects/session-manager',
|
|
264
|
+
summary: { title: 'Example Project' },
|
|
265
|
+
picks: { home: { hero: 'variant-a' } },
|
|
266
|
+
},
|
|
267
|
+
notes: "cwd is optional — defaults to the calling session's own project root (SM_PROJECT_ROOT or process.cwd()) when omitted.",
|
|
268
|
+
},
|
|
269
|
+
{
|
|
270
|
+
name: 'project_home_status',
|
|
271
|
+
group: 'project-home',
|
|
272
|
+
purpose: "Report what already exists for this project's Project Home generation via the session-manager "
|
|
273
|
+
+ "app's admin API — summary.json/picks.json/each lens file's existence + mtime, and manifest.json's "
|
|
274
|
+
+ 'generatedAt.',
|
|
275
|
+
whenToUse: 'Use before starting generation to see prior state, and again after project_home_render to '
|
|
276
|
+
+ 'confirm the new files landed.',
|
|
277
|
+
whenNotToUse: 'Do not treat an all-absent result as an error — a project that has never generated a '
|
|
278
|
+
+ 'Project Home page yet still returns a well-formed status with every exists:false rather than failing.',
|
|
279
|
+
exampleArgs: { cwd: '/home/bilko/Projects/session-manager' },
|
|
280
|
+
notes: "cwd is optional — defaults to the calling session's own project root (SM_PROJECT_ROOT or process.cwd()) when omitted.",
|
|
281
|
+
},
|
|
216
282
|
{
|
|
217
283
|
name: 'session_manager_help',
|
|
218
284
|
group: 'help',
|
|
@@ -263,6 +329,25 @@ const MCP_RECIPES = [
|
|
|
263
329
|
'The call only delivers a PROPOSED session in the receiving project — nothing runs until a human there presses Approve & start; there is no reply channel.',
|
|
264
330
|
],
|
|
265
331
|
},
|
|
332
|
+
{
|
|
333
|
+
id: 'generate-project-home',
|
|
334
|
+
title: "Generate a project's Project Home pages",
|
|
335
|
+
steps: [
|
|
336
|
+
"Call project_home_get_contract to get the protocol, ProjectPageSummary/ProjectPagePicks schemas, "
|
|
337
|
+
+ 'component catalog, absolute output paths, and pipeline spec — the FIRST call any '
|
|
338
|
+
+ 'project-home-builder session makes.',
|
|
339
|
+
'Author a ProjectPageSummary matching the contract\'s summarySchema — every field must trace to '
|
|
340
|
+
+ 'something concrete about the real project; never fabricate content.',
|
|
341
|
+
'Call project_home_validate_summary with { cwd, summary } and fix every {field, message} error it '
|
|
342
|
+
+ 'returns, re-validating until valid:true.',
|
|
343
|
+
"For each lens and slot in the contract's catalog, choose exactly one variant id by judging the "
|
|
344
|
+
+ 'candidate variants against the summary you composed, assembling a ProjectPagePicks object '
|
|
345
|
+
+ '(lensId -> slotId -> variantId).',
|
|
346
|
+
'Call project_home_render with { cwd, summary, picks } to render all 5 lenses and write '
|
|
347
|
+
+ 'summary.json/picks.json/output files.',
|
|
348
|
+
"Call project_home_status to confirm the new files landed and read manifest.json's generatedAt.",
|
|
349
|
+
],
|
|
350
|
+
},
|
|
266
351
|
];
|
|
267
352
|
|
|
268
353
|
for (const entry of MCP_TOOL_CATALOG) {
|
|
@@ -48,6 +48,18 @@ const OWNERS = Object.freeze({
|
|
|
48
48
|
'scheduler': 'scheduler',
|
|
49
49
|
// Project Home owns the synthesized Brief (generate + hand-edit).
|
|
50
50
|
'project-brief': 'project-home',
|
|
51
|
+
// Project Home also owns the app-side render path for Project Pages
|
|
52
|
+
// (summary.json/picks.json/output/* written via the
|
|
53
|
+
// /admin/project-home/render route, config.cjs's writeJson). This is a
|
|
54
|
+
// NARROWER claim than "project-home owns everything under this folder":
|
|
55
|
+
// the SAME folder is also written directly by a project-home-builder
|
|
56
|
+
// Epic's own Write tool, which never goes through config.cjs and so is
|
|
57
|
+
// NOT governed by this table at all (see
|
|
58
|
+
// session-manager-operations/project-pages/README.md). Declaring the
|
|
59
|
+
// namespace here only closes the fail-closed gap for the app's own write
|
|
60
|
+
// path — it does not claim exclusivity over the agent's Write-tool calls,
|
|
61
|
+
// which remain outside this law by construction.
|
|
62
|
+
'project-pages': 'project-home',
|
|
51
63
|
// Structured per-tab error log lines (JSONL), tagged for tracing/analysis.
|
|
52
64
|
'logs': 'logs',
|
|
53
65
|
// Host on Bilko.run tab's deterministic bundle prep (dist/index.html +
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* prdAgentType.cjs — the PRD-frontmatter `agentType` FK, mirroring
|
|
5
|
+
* agentModelResolve.cjs's/epicMint.cjs's established convention for the
|
|
6
|
+
* Epic-level `agentType` FK: THROW ON WRITE, REPORT ON READ.
|
|
7
|
+
*
|
|
8
|
+
* A PRD's `agentType` names the persona (`~/.claude/agents/<name>.md`, or a
|
|
9
|
+
* project overlay at `<cwd>/.claude/agents/<name>.md`) that should execute
|
|
10
|
+
* it — distinct from `tag` (workTypeLibrary.cjs), which is the WORK TYPE.
|
|
11
|
+
* Reuses epicMint.cjs's personaFileExists/resolvePersonaPaths rather than a
|
|
12
|
+
* second persona-file reader, and agentLibrary.cjs's listPersonas() for the
|
|
13
|
+
* "available personas" name list surfaced in the write-time rejection error.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
const { personaFileExists, resolvePersonaPaths } = require('./epicMint.cjs');
|
|
17
|
+
const { listPersonas } = require('../agentLibrary.cjs');
|
|
18
|
+
|
|
19
|
+
/** PRD execution's documented default persona (dev-lead.md's own frontmatter role). */
|
|
20
|
+
const DEFAULT_PRD_AGENT_TYPE = 'dev-lead';
|
|
21
|
+
|
|
22
|
+
// READ side of the FK — logged at most once per (cwd, agentType) so a hot
|
|
23
|
+
// reconcile/list pass re-parsing the same on-disk PRD every tick doesn't
|
|
24
|
+
// flood opsErrorLog's daily file. Mirrors agentModelResolve.cjs's
|
|
25
|
+
// loggedDanglingPersonas set.
|
|
26
|
+
const loggedDanglingAgentTypes = new Set();
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Read-time tolerance check: a PRD on disk whose persona was renamed or
|
|
30
|
+
* deleted after the PRD was written must still load. Never throws — logs at
|
|
31
|
+
* most once via opsErrorLog when `agentType` is set but unresolvable.
|
|
32
|
+
*/
|
|
33
|
+
function reportDanglingAgentTypeOnce(cwd, agentType, deps = {}) {
|
|
34
|
+
if (!cwd || !agentType) return;
|
|
35
|
+
try {
|
|
36
|
+
const checkPersonaExists = deps.personaExists || personaFileExists;
|
|
37
|
+
if (checkPersonaExists(cwd, agentType, deps)) return;
|
|
38
|
+
const key = `${cwd}::${agentType}`;
|
|
39
|
+
if (loggedDanglingAgentTypes.has(key)) return;
|
|
40
|
+
loggedDanglingAgentTypes.add(key);
|
|
41
|
+
// Required lazily, same rationale as agentModelResolve.cjs's
|
|
42
|
+
// logDanglingPersonaOnce: pay for this leaf module only on the (rare)
|
|
43
|
+
// miss path.
|
|
44
|
+
const { appendError } = require('./opsErrorLog.cjs');
|
|
45
|
+
appendError({
|
|
46
|
+
cwd,
|
|
47
|
+
scope: 'prdAgentType',
|
|
48
|
+
level: 'warn',
|
|
49
|
+
message: `PRD agentType '${agentType}' has no resolvable persona file — PRD still loads`,
|
|
50
|
+
meta: { agentType },
|
|
51
|
+
});
|
|
52
|
+
} catch { /* logging must never break PRD loading */ }
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Write-time FK check: throws when `agentType` is supplied but doesn't
|
|
57
|
+
* resolve to a readable persona file, naming the available personas (from
|
|
58
|
+
* the Agent Library) so the caller can pick a real one. A no-op when
|
|
59
|
+
* `agentType` is falsy — the caller is expected to default it separately
|
|
60
|
+
* (buildPrdBody's DEFAULT_PRD_AGENT_TYPE fill-in).
|
|
61
|
+
*/
|
|
62
|
+
async function assertAgentTypeWritable(cwd, agentType, deps = {}) {
|
|
63
|
+
if (!agentType) return;
|
|
64
|
+
const checkPersonaExists = deps.personaExists || personaFileExists;
|
|
65
|
+
if (checkPersonaExists(cwd, agentType, deps)) return;
|
|
66
|
+
|
|
67
|
+
const load = deps.listPersonas || listPersonas;
|
|
68
|
+
let available = [];
|
|
69
|
+
try {
|
|
70
|
+
available = (await load()).map((p) => p.name).filter(Boolean).sort();
|
|
71
|
+
} catch { /* best-effort — still throw below even if listing failed */ }
|
|
72
|
+
|
|
73
|
+
const { projectPath, globalPath } = resolvePersonaPaths(cwd, agentType, deps);
|
|
74
|
+
throw new Error(
|
|
75
|
+
`agentType '${agentType}' does not resolve to a readable persona file — checked ${projectPath} and `
|
|
76
|
+
+ `${globalPath}. Available personas: ${available.length ? available.join(', ') : '(none found)'}`,
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
module.exports = {
|
|
81
|
+
DEFAULT_PRD_AGENT_TYPE,
|
|
82
|
+
assertAgentTypeWritable,
|
|
83
|
+
reportDanglingAgentTypeOnce,
|
|
84
|
+
};
|