great-cto 3.63.0 → 3.63.2
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/board/.claude-plugin/plugin.json +2 -2
- package/board/scripts/lib/agent-events.mjs +9 -3
- package/board/scripts/lib/codex-host-state.mjs +3 -2
- package/board/scripts/lib/codex-pipeline.mjs +22 -14
- package/board/scripts/lib/great-cto-scope.mjs +20 -2
- package/board/scripts/lib/invocation-identity.mjs +10 -0
- package/board/scripts/lib/session-status.mjs +4 -1
- package/package.json +1 -1
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "great-cto",
|
|
3
3
|
"description": "You already have the agent. This is everything around it. great_cto runs Claude Code as a pipeline of 72 specialist agents \u2014 an independent model checks each stage before the next builds on it, spending caps refuse rather than warn, and three decisions stay yours: what gets built, how, and whether it ships.",
|
|
4
|
-
"version": "3.63.
|
|
4
|
+
"version": "3.63.2",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "Alexander Velikiy",
|
|
7
7
|
"url": "https://hashnode.com/@Greatcto"
|
|
@@ -60,7 +60,7 @@
|
|
|
60
60
|
},
|
|
61
61
|
{
|
|
62
62
|
"type": "command",
|
|
63
|
-
"command": "_gd=\"$PWD\"; while [ -n \"$_gd\" ] && { [ \"$_gd\" = \"$HOME\" ] || [ ! -f \"$_gd/.great_cto/PROJECT.md\" ]; }; do _gd=\"${_gd%/*}\"; done; [ -n \"$_gd\" ] && cd \"$_gd\"; export PATH=\"/opt/homebrew/bin:$HOME/.local/bin:/usr/local/bin:$PATH\"; PLUGIN_DIR=${CLAUDE_PLUGIN_ROOT:-$(ls -d ~/.claude/plugins/cache/*/great_cto/*/ 2>/dev/null | awk -F'/plugins/cache/' '{split($NF,p,\"/\"); print p[3], $0}' | sort -V | tail -1 | cut -d' ' -f2- | sed 's|/$||')}; if [ -z \"$PLUGIN_DIR\" ]; then echo 'GREAT_CTO: plugin dir not found \u2014 run /update'; else mkdir -p ~/.great_cto
|
|
63
|
+
"command": "_gd=\"$PWD\"; while [ -n \"$_gd\" ] && { [ \"$_gd\" = \"$HOME\" ] || [ ! -f \"$_gd/.great_cto/PROJECT.md\" ]; }; do _gd=\"${_gd%/*}\"; done; [ -n \"$_gd\" ] && cd \"$_gd\"; export PATH=\"/opt/homebrew/bin:$HOME/.local/bin:/usr/local/bin:$PATH\"; PLUGIN_DIR=${CLAUDE_PLUGIN_ROOT:-$(ls -d ~/.claude/plugins/cache/*/great_cto/*/ 2>/dev/null | awk -F'/plugins/cache/' '{split($NF,p,\"/\"); print p[3], $0}' | sort -V | tail -1 | cut -d' ' -f2- | sed 's|/$||')}; if [ -z \"$PLUGIN_DIR\" ]; then echo 'GREAT_CTO: plugin dir not found \u2014 run /update'; else mkdir -p ~/.great_cto; node \"${PLUGIN_DIR}/scripts/lib/sync-managed.mjs\" --plugin-dir \"${PLUGIN_DIR}\" 2>/dev/null || true; if [ -f .great_cto/PROJECT.md ]; then cp \"${PLUGIN_DIR}/skills/great_cto/ARCHETYPES.md\" .great_cto/ARCHETYPES.md 2>/dev/null || true; cp \"${PLUGIN_DIR}/skills/great_cto/SKILL.md\" .great_cto/SKILL.md 2>/dev/null || true; { printf \"export PATH=/opt/homebrew/bin:$HOME/.local/bin:/usr/local/bin:$PATH\\nexport ARCHETYPES_MD=.great_cto/ARCHETYPES.md\\n\"; } > .great_cto/env.sh; fi; fi; if [ ! -d ~/.great_cto/catalog ]; then git clone --depth=1 https://github.com/davila7/claude-code-templates.git ~/.great_cto/catalog 2>/dev/null & fi; if [ ! -d ~/.great_cto/anthropic-skills ]; then git clone --depth=1 https://github.com/anthropics/skills.git ~/.great_cto/anthropic-skills 2>/dev/null & elif [ $(($(date +%s) - $(stat -f %m ~/.great_cto/anthropic-skills/.git 2>/dev/null || stat -c %Y ~/.great_cto/anthropic-skills/.git 2>/dev/null || echo 0))) -gt 604800 ]; then (cd ~/.great_cto/anthropic-skills && git pull --quiet 2>/dev/null) & fi; if [ ! -d ~/.great_cto/personal-skills ]; then git clone --depth=1 https://github.com/avelikiy/ai-agent-skills.git ~/.great_cto/personal-skills 2>/dev/null & elif [ $(($(date +%s) - $(stat -f %m ~/.great_cto/personal-skills/.git 2>/dev/null || stat -c %Y ~/.great_cto/personal-skills/.git 2>/dev/null || echo 0))) -gt 86400 ]; then (cd ~/.great_cto/personal-skills && git pull --quiet 2>/dev/null) & fi; if [ ! -f ~/.great_cto/skills-registry.json ] || [ $(($(date +%s) - $(stat -f %m ~/.great_cto/skills-registry.json 2>/dev/null || stat -c %Y ~/.great_cto/skills-registry.json 2>/dev/null || echo 0))) -gt 86400 ]; then [ -x \"${PLUGIN_DIR}/scripts/skill-discover.sh\" ] && bash \"${PLUGIN_DIR}/scripts/skill-discover.sh\" >/dev/null 2>&1 & fi; [ -x \"${PLUGIN_DIR}/scripts/hooks/welcome.sh\" ] && bash \"${PLUGIN_DIR}/scripts/hooks/welcome.sh\" 2>/dev/null || true; bash \"${PLUGIN_DIR}/scripts/apply-model-override.sh\" 2>/dev/null || true; bd prime 2>/dev/null || true; cat \"${PLUGIN_DIR}/scripts/hooks/operating-rules.md\" 2>/dev/null || true; echo '=== PREFERENCES ==='; node \"${PLUGIN_DIR}/scripts/hooks/read-global-memory.mjs\" 2>&1 || true; echo '=== PROJECT ==='; cat .great_cto/PROJECT.md 2>/dev/null || echo 'GREAT_CTO: No PROJECT.md \u2014 run /start'; PHASE=$(grep '^phase:' .great_cto/PROJECT.md 2>/dev/null | awk '{print $2}' || echo implementation); echo '=== PHASE ==='; echo \"$PHASE\"; { [ \"$PHASE\" != review ] && [ \"$PHASE\" != release ]; } && { echo '=== BRAIN ==='; head -30 .great_cto/brain.md 2>/dev/null; } || true; [ \"$PHASE\" = implementation ] && { echo '=== CODEBASE ==='; head -20 .great_cto/CODEBASE.md 2>/dev/null; } || true; echo '=== LOCAL ==='; cat .great_cto/local.md 2>/dev/null || true; [ \"$PHASE\" != planning ] && { echo '=== HANDOFF ==='; cat .great_cto/HANDOFF.md 2>/dev/null || echo 'No handoff state \u2014 fresh session'; } || true; [ \"$PHASE\" = review ] && { echo '=== LATEST QA ==='; ls docs/qa-reports/QA-*.md 2>/dev/null | awk -F'/plugins/cache/' '{split($NF,p,\"/\"); print p[3], $0}' | sort -V | tail -1 | cut -d' ' -f2- | xargs head -20 2>/dev/null; echo '=== LATEST CSO ==='; ls docs/security/CSO-*.md 2>/dev/null | awk -F'/plugins/cache/' '{split($NF,p,\"/\"); print p[3], $0}' | sort -V | tail -1 | cut -d' ' -f2- | xargs head -20 2>/dev/null; } || true; [ \"$PHASE\" = release ] && { echo '=== PERF BASELINE ==='; tail -10 .great_cto/perf-baseline.log 2>/dev/null; } || true; echo '=== STATUS ==='; OPEN_GATES=$(bd list --label gate --status open 2>/dev/null | wc -l | tr -d ' '); OPEN_TASKS=$(bd list --status open 2>/dev/null | wc -l | tr -d ' '); BRANCH=$(git branch --show-current 2>/dev/null || echo '?'); LAST_AGENT=$(ls -t .great_cto/verdicts/*.log 2>/dev/null | head -1 | xargs tail -1 2>/dev/null | awk '{print $2}' || echo 'none'); printf 'branch=%s | gates=%s | tasks=%s | last_agent=%s\\n' \"$BRANCH\" \"${OPEN_GATES:-0}\" \"${OPEN_TASKS:-0}\" \"${LAST_AGENT:-none}\"; [ \"${OPEN_GATES:-0}\" -gt 0 ] && echo '\u2192 run /inbox to see pending decisions' || true; P0_OPEN=$(bd list --status open 2>/dev/null | grep -c 'P0'); P0_OPEN=${P0_OPEN:-0}; if [ \"$P0_OPEN\" -gt 0 ]; then echo ''; printf '\\033[41;97m \ud83d\udea8 P0 OPEN: %s \u2014 run /inbox NOW \\033[0m\\n' \"$P0_OPEN\"; bd list --status open 2>/dev/null | grep 'P0' | head -3 | sed 's/^/ /'; fi; NOW_EPOCH=$(date +%s); LAST_AUDIT_FILE=$(find docs/audit -maxdepth 1 -name 'AUDIT-*.md' 2>/dev/null | sort | tail -1); if [ -n \"$LAST_AUDIT_FILE\" ]; then AUD_M=$(stat -f %m \"$LAST_AUDIT_FILE\" 2>/dev/null || stat -c %Y \"$LAST_AUDIT_FILE\" 2>/dev/null || echo $NOW_EPOCH); AUD_AGE=$(( (NOW_EPOCH - AUD_M) / 86400 )); [ \"$AUD_AGE\" -gt 30 ] && echo \"\u26a0 audit $AUD_AGE days old \u2014 run /audit\"; else echo '\u26a0 no audit artefact \u2014 run /audit or /doctor'; fi; if [ -f .great_cto/digest-latest.md ]; then DIG_M=$(stat -f %m .great_cto/digest-latest.md 2>/dev/null || stat -c %Y .great_cto/digest-latest.md 2>/dev/null || echo $NOW_EPOCH); DIG_AGE=$(( (NOW_EPOCH - DIG_M) / 86400 )); [ \"$DIG_AGE\" -gt 8 ] && echo \"\u26a0 digest $DIG_AGE days old \u2014 run /digest 7\"; fi; [ \"$PHASE\" != implementation ] && cat .great_cto/digest-latest.md 2>/dev/null | head -5 || true; [ -f \"${PLUGIN_DIR}/scripts/hooks/auto-attach-reviewers.mjs\" ] && node \"${PLUGIN_DIR}/scripts/hooks/auto-attach-reviewers.mjs\" 2>/dev/null; [ -n \"$PLUGIN_DIR\" ] && [ -f .great_cto/PROJECT.md ] && [ ! -f .great_cto/CODEBASE.md ] && node \"${PLUGIN_DIR}/scripts/first-run-codebase.mjs\" >/dev/null 2>&1 & node \"${PLUGIN_DIR}/scripts/hooks/quota-check.mjs\" 2>/dev/null || true; echo '=== PATTERNS ==='; GP_DIR=\"$HOME/.great_cto/global-patterns\"; ARCH=$(grep '^primary:' .great_cto/PROJECT.md 2>/dev/null | awk '{print $2}' | head -1); if [ -d \"$GP_DIR\" ] && ls \"$GP_DIR\"/GP-*.md >/dev/null 2>&1; then ACTIVE_COUNT=$(grep -rl 'status: active' \"$GP_DIR\" 2>/dev/null | wc -l | tr -d ' '); echo \"Active patterns: ${ACTIVE_COUNT:-0}\"; if [ -n \"$ARCH\" ] && [ \"${ACTIVE_COUNT:-0}\" -gt 0 ]; then grep -rl 'status: active' \"$GP_DIR\" 2>/dev/null | xargs grep -lE \"applies_to:.*$ARCH\" 2>/dev/null | sort -V | tail -5 | while read f; do SLUG=$(basename \"$f\" .md); SYMPT=$(grep -m1 '^symptom:' \"$f\" 2>/dev/null | sed 's/symptom: //'); DETECT=$(grep -A 2 '^detection_order:' \"$f\" 2>/dev/null | grep '^ - ' | head -1 | sed 's/^ - //'); HITS=$(grep -m1 '^hits:' \"$f\" 2>/dev/null | awk '{print $2}'); printf ' %s (hits=%s): %s\\n \\u2192 Check first: %s\\n' \"$SLUG\" \"${HITS:-0}\" \"$SYMPT\" \"$DETECT\"; done || echo \" no patterns match archetype=$ARCH yet \u2014 /crystallize builds the library\"; fi; else echo 'no global patterns yet \u2014 run /crystallize after first incident'; fi; [ -x \"${PLUGIN_DIR}/scripts/check-update.sh\" ] && bash \"${PLUGIN_DIR}/scripts/check-update.sh\" \"${PLUGIN_DIR}/.claude-plugin/plugin.json\" 2>/dev/null || true ; # great_cto-cache-cleanup: keep the newest 3 cached versions, and never one a live session runs from (prune-versions; the check install-local --prune got after 2026-09-11). for CR in \"$HOME\"/.claude/plugins/cache/*/great_cto; do [ -d \"$CR\" ] || continue; node \"${PLUGIN_DIR}/scripts/lib/prune-versions.mjs\" --cache-root \"$CR\" --keep \"${PLUGIN_DIR:-$CR/none}\" --keep-newest 3 2>/dev/null | while IFS= read -r d; do case \"$d\" in \"$CR\"/*) rm -rf \"$d\" ;; esac; done; done 2>/dev/null || true",
|
|
64
64
|
"timeout": 30,
|
|
65
65
|
"statusMessage": "Loading Great CTO context..."
|
|
66
66
|
},
|
|
@@ -24,7 +24,7 @@
|
|
|
24
24
|
// CLI (for hooks written in shell):
|
|
25
25
|
// node scripts/lib/agent-events.mjs --emit <kind> [--tool <name>] [--agent <name>]
|
|
26
26
|
|
|
27
|
-
import { isGlobalLayer } from './great-cto-scope.mjs';
|
|
27
|
+
import { isGlobalLayer, isProjectState } from './great-cto-scope.mjs';
|
|
28
28
|
import { appendFileSync, closeSync, fstatSync, mkdirSync, openSync, readFileSync, readSync, renameSync, statSync } from 'node:fs';
|
|
29
29
|
import { join } from 'node:path';
|
|
30
30
|
|
|
@@ -32,7 +32,7 @@ export const EVENTS_FILE = 'events.jsonl';
|
|
|
32
32
|
// `hint`: a context hook (edit-impact, lesson-tripwire) put text in front of the model.
|
|
33
33
|
// Recorded so its noise can be measured — how often, on what, how long — not what it said.
|
|
34
34
|
export const EVENT_KINDS = Object.freeze(['agent-start', 'agent-stop', 'tool', 'denied', 'stop', 'pipeline', 'hint']);
|
|
35
|
-
export const EVENT_FIELDS = Object.freeze(['v', 'ts', 'kind', 'session', 'agent', 'tool', 'paths', 'ok', 'duration_ms', 'outcome', 'verdict', 'hook', 'chars', 'host']);
|
|
35
|
+
export const EVENT_FIELDS = Object.freeze(['v', 'ts', 'kind', 'session', 'agent', 'tool', 'paths', 'ok', 'duration_ms', 'outcome', 'verdict', 'hook', 'chars', 'host', 'invocation_id']);
|
|
36
36
|
export const MAX_BYTES = 5 * 1024 * 1024;
|
|
37
37
|
|
|
38
38
|
const MAX_PATHS = 10;
|
|
@@ -49,6 +49,7 @@ export function makeEvent(input, { now = Date.now() } = {}) {
|
|
|
49
49
|
const e = { v: 1, ts: iso(now), kind: input.kind };
|
|
50
50
|
const session = short(input.session, 80); if (session) e.session = session;
|
|
51
51
|
const agent = short(input.agent, 80); if (agent) e.agent = agent;
|
|
52
|
+
if (typeof input.invocation_id === 'string' && /^[a-f0-9]{64}$/.test(input.invocation_id)) e.invocation_id = input.invocation_id;
|
|
52
53
|
const tool = short(input.tool, 80); if (tool) e.tool = tool;
|
|
53
54
|
if (Array.isArray(input.paths)) {
|
|
54
55
|
const paths = input.paths.filter((p) => typeof p === 'string' && p.trim()).map((p) => p.slice(0, 300)).slice(0, MAX_PATHS);
|
|
@@ -70,11 +71,16 @@ export function makeEvent(input, { now = Date.now() } = {}) {
|
|
|
70
71
|
* @param {string} dir the project's .great_cto directory
|
|
71
72
|
* @returns {{ok:true} | {ok:false, why:string}}
|
|
72
73
|
*/
|
|
73
|
-
export function appendEvent(dir, input, { now = Date.now(), env = process.env, maxBytes = MAX_BYTES } = {}) {
|
|
74
|
+
export function appendEvent(dir, input, { now = Date.now(), env = process.env, maxBytes = MAX_BYTES, explicit = false } = {}) {
|
|
74
75
|
try {
|
|
75
76
|
if (env.GREAT_CTO_DISABLE_EVENTS === '1') return { ok: false, why: 'disabled by GREAT_CTO_DISABLE_EVENTS=1' };
|
|
76
77
|
// ~/.great_cto is every project's global layer, never one project's log.
|
|
77
78
|
if (isGlobalLayer(dir)) return { ok: false, why: 'the global layer is not a project' };
|
|
79
|
+
// Hooks fire in every repository a session opens, great_cto project or not;
|
|
80
|
+
// creating .great_cto there wrote into someone else's tree (great_cto-tng1).
|
|
81
|
+
// So a hook's event needs a project (.great_cto/PROJECT.md or verdicts/).
|
|
82
|
+
// `explicit` is a command the user ran in this repository (great-cto run).
|
|
83
|
+
if (!explicit && !isProjectState(dir)) return { ok: false, why: 'not a great_cto project' };
|
|
78
84
|
const e = makeEvent(input, { now });
|
|
79
85
|
if (!e) return { ok: false, why: `not an event kind: ${JSON.stringify(input?.kind)}` };
|
|
80
86
|
const line = JSON.stringify(e) + '\n';
|
|
@@ -23,8 +23,9 @@ export function projectCodexState(state) {
|
|
|
23
23
|
controllerDispatch: dispatchEvidenceSummary(state),
|
|
24
24
|
wave: state.wave ? { id: state.wave.id, roles: state.wave.roles, hosts: state.wave.hosts, status: state.wave.status } : null,
|
|
25
25
|
rolesCompleted: Object.keys(state.results || {}),
|
|
26
|
-
attempts: (state.attempts || []).map(({ id, role, host, number, phase, status, startedAt, finishedAt }) =>
|
|
27
|
-
({ id, role, host: host || 'codex', number, phase, status, startedAt, finishedAt
|
|
26
|
+
attempts: (state.attempts || []).map(({ id, role, host, number, phase, status, startedAt, finishedAt, invocationId }) =>
|
|
27
|
+
({ id, role, host: host || 'codex', number, phase, status, startedAt, finishedAt,
|
|
28
|
+
...(invocationId ? { invocationId } : {}) })),
|
|
28
29
|
pending: state.pending ? { role: state.pending.role, gates: state.pending.gates || [], createdAt: state.pending.createdAt } : null,
|
|
29
30
|
release: state.release ? {
|
|
30
31
|
adapter: state.release.adapter,
|
|
@@ -18,6 +18,7 @@ import { readExecutionBudget, withAgentBudget, requireAgents, releaseAgent, budg
|
|
|
18
18
|
import { validateSpecialistPolicy, assertSpecialistEpoch, schedulePreparation, scheduleSpecialists, specialistRole, validateReviewFiles, recordReviewFiles } from './controlled-specialists.mjs';
|
|
19
19
|
import { scopedReviewInput, scopedReviewCandidate, attestScopedReview, completeScopeAttestation } from './scoped-review-reuse.mjs';
|
|
20
20
|
import { observeControllerCall } from './controller-dispatch-evidence.mjs';
|
|
21
|
+
import { invocationIdentity } from './invocation-identity.mjs';
|
|
21
22
|
|
|
22
23
|
export const PLUGIN_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '../..');
|
|
23
24
|
const hash = text => createHash('sha256').update(text).digest('hex');
|
|
@@ -68,11 +69,12 @@ function cleanResponse(response) {
|
|
|
68
69
|
* is excluded from receipts, so these writes cannot trip the tree checks below.
|
|
69
70
|
*/
|
|
70
71
|
function emit(state, event) {
|
|
71
|
-
|
|
72
|
+
// The user ran this pipeline here, so it keeps its log here even before /start.
|
|
73
|
+
return appendEvent(join(state.root, '.great_cto'), { ...event, session: state.id }, { explicit: true });
|
|
72
74
|
}
|
|
73
|
-
const toolListener = (state, agent) => (ev) => {
|
|
75
|
+
const toolListener = (state, agent, invocationId = null) => (ev) => {
|
|
74
76
|
const e = codexToolEvent(ev);
|
|
75
|
-
if (e) emit(state, { ...e, agent });
|
|
77
|
+
if (e) emit(state, { ...e, agent, invocation_id: invocationId });
|
|
76
78
|
};
|
|
77
79
|
|
|
78
80
|
/**
|
|
@@ -171,16 +173,18 @@ function releaseSummary(state) {
|
|
|
171
173
|
|
|
172
174
|
export async function verifyStage(state, role, proposal, execute) {
|
|
173
175
|
const agent = 'codex-verifier';
|
|
176
|
+
const invocationId = invocationIdentity({ session_id: state.id,
|
|
177
|
+
agent_id: `${state.attempts?.findLast(a => a.role === role)?.id || randomUUID()}:verifier` });
|
|
174
178
|
const t0 = Date.now();
|
|
175
179
|
let ok = false;
|
|
176
|
-
emit(state, { kind: 'agent-start', agent });
|
|
180
|
+
emit(state, { kind: 'agent-start', agent, invocation_id: invocationId });
|
|
177
181
|
const wave = waveEvidence(state);
|
|
178
182
|
const attestWorkflow = wave?.roles.includes(role);
|
|
179
183
|
try {
|
|
180
184
|
const result = cleanResponse(await execute({
|
|
181
185
|
cwd: state.root, sandbox: 'read-only', ephemeral: true, extraArgs: workerArgs,
|
|
182
186
|
bin: process.env.GREAT_CTO_CODEX_BIN || 'codex', timeoutMs: 300000,
|
|
183
|
-
onEvent: toolListener(state, agent),
|
|
187
|
+
invocationId, onEvent: toolListener(state, agent, invocationId),
|
|
184
188
|
prompt: `You are an independent verifier for the ${role} stage. Read the ACTUAL files and assess whether they satisfy the task for this stage.\n` +
|
|
185
189
|
frozenPolicyContext(state) +
|
|
186
190
|
(state.specialistStages?.[role] ? `Controlled phase: ${state.specialistStages[role].phase}, before implementation. Verify an implementable contract/threat model and its evidence; do not require nonexistent implementation or treat design sign-off as code approval. Capability: ${codexRoleProfile(specialistRole(state, role))}\n` : '') +
|
|
@@ -224,7 +228,7 @@ export async function verifyStage(state, role, proposal, execute) {
|
|
|
224
228
|
ok = true;
|
|
225
229
|
return result;
|
|
226
230
|
} finally {
|
|
227
|
-
emit(state, { kind: 'agent-stop', agent, ok, duration_ms: Date.now() - t0 });
|
|
231
|
+
emit(state, { kind: 'agent-stop', agent, invocation_id: invocationId, ok, duration_ms: Date.now() - t0 });
|
|
228
232
|
}
|
|
229
233
|
}
|
|
230
234
|
|
|
@@ -629,11 +633,12 @@ export async function runStage(state, { execute = null, runners = { codex: runCo
|
|
|
629
633
|
const head = workerHead(state, role);
|
|
630
634
|
// Additive v1 fields: old runs retain their original single-attempt policy.
|
|
631
635
|
state.attempts ??= [];
|
|
632
|
-
const attempt = { id: randomUUID(), role, number: state.attempts.filter(a => a.role === role).length + 1,
|
|
636
|
+
const attempt = { id: prepared?.attemptId || randomUUID(), role, number: state.attempts.filter(a => a.role === role).length + 1,
|
|
633
637
|
host: roleHost(state, role), status: 'running', phase: 'worker', startedAt: new Date().toISOString(),
|
|
634
638
|
inputReceipt: stageReceipt(state, prepared ? prepared.receipt : treeReceipt(state.root), 'stage input') };
|
|
635
639
|
if (prepared?.callId) attempt.workerCallId = prepared.callId;
|
|
636
640
|
if (attempt.number > (state.maxAttempts ?? 1)) throw Error('stage attempt limit reached');
|
|
641
|
+
attempt.invocationId = invocationIdentity({ session_id: state.id, agent_id: attempt.id });
|
|
637
642
|
state.attempts.push(attempt);
|
|
638
643
|
state.active = role; state.steps++; save(state);
|
|
639
644
|
// Recorded only once the stage is really dispatched: a run awaiting a gate, or
|
|
@@ -692,10 +697,10 @@ export async function runStage(state, { execute = null, runners = { codex: runCo
|
|
|
692
697
|
}
|
|
693
698
|
if (!prepared && typeof runner !== 'function') throw Error(`no runner for ${roleHost(state, role)}`);
|
|
694
699
|
const response = prepared ? prepared.response : reused || await withAgentBudget(state, { callId: `${attempt.id}:worker`, host: roleHost(state, role), role }, async () => {
|
|
695
|
-
stageStarted = true; emit(state, { kind: 'agent-start', agent });
|
|
700
|
+
stageStarted = true; emit(state, { kind: 'agent-start', agent, invocation_id: attempt.invocationId });
|
|
696
701
|
return observeControllerCall(state, { id: `${attempt.id}:worker`, host: roleHost(state, role), role, kind: 'worker' }, () => runner({ prompt, cwd: state.root, sandbox: 'read-only', ephemeral: true,
|
|
697
702
|
bin: roleHost(state, role) === 'codex' ? process.env.GREAT_CTO_CODEX_BIN || 'codex' : process.env.GREAT_CTO_CLAUDE_BIN || 'claude',
|
|
698
|
-
timeoutMs: 300000, extraArgs: roleHost(state, role) === 'codex' ? workerArgs : [], onEvent: toolListener(state, agent) }), save);
|
|
703
|
+
invocationId: attempt.invocationId, timeoutMs: 300000, extraArgs: roleHost(state, role) === 'codex' ? workerArgs : [], onEvent: toolListener(state, agent, attempt.invocationId) }), save);
|
|
699
704
|
});
|
|
700
705
|
// Codex can recover its session-index lookup without degrading the worker.
|
|
701
706
|
// Keep the diagnostic in the receipt; every other warning/error blocks.
|
|
@@ -806,7 +811,7 @@ export async function runStage(state, { execute = null, runners = { codex: runCo
|
|
|
806
811
|
// re-reviews the candidate. Human gates and repair limits remain unchanged.
|
|
807
812
|
if (state.rework?.role === role) state.rework = null;
|
|
808
813
|
state.results[role] = { verdict: proposal.verdict, summary: proposal.summary, meta: proposal.meta || {},
|
|
809
|
-
attemptId: attempt.id, host: roleHost(state, role), checks: attempt.checks ?? null, receipt,
|
|
814
|
+
attemptId: attempt.id, invocationId: attempt.invocationId, host: roleHost(state, role), checks: attempt.checks ?? null, receipt,
|
|
810
815
|
receiptEvidence: receipt ? 'git-tree' : 'report-artifacts-only', verification,
|
|
811
816
|
digest: hash(JSON.stringify({ attemptId: attempt.id, proposal })), usage: response.usage ?? null,
|
|
812
817
|
diagnostics: response.errors || [], at: new Date().toISOString() };
|
|
@@ -825,7 +830,7 @@ export async function runStage(state, { execute = null, runners = { codex: runCo
|
|
|
825
830
|
// Keep active set: a partial write or interrupted process must not be replayed.
|
|
826
831
|
save(state);
|
|
827
832
|
} finally {
|
|
828
|
-
if (stageStarted) emit(state, { kind: 'agent-stop', agent, ok: stageOk, duration_ms: Date.now() - t0 });
|
|
833
|
+
if (stageStarted) emit(state, { kind: 'agent-stop', agent, invocation_id: attempt.invocationId, ok: stageOk, duration_ms: Date.now() - t0 });
|
|
829
834
|
// ADR-023: a Codex stage is a turn. Recorded after every tree check above, and it
|
|
830
835
|
// writes only git objects and a ref, never a working file. Never throws.
|
|
831
836
|
if (snapshotTurn(state.root, { session: state.id }).state === 'recorded') pruneTurns(state.root, { session: state.id, keep: 50 });
|
|
@@ -893,13 +898,15 @@ export async function runParallelWave(state, { runners = { codex: runCodexExec,
|
|
|
893
898
|
const waveId = randomUUID();
|
|
894
899
|
const leases = requireAgents(state.executionBudget, roles.map(role => ({ callId: `${waveId}:${role}`, host: roleHost(state, role), role, depth: 1 })));
|
|
895
900
|
state.wave = { id: waveId, roles, status: 'running', receipt,
|
|
901
|
+
attemptIds: Object.fromEntries(roles.map(role => [role, randomUUID()])),
|
|
896
902
|
hosts: Object.fromEntries(roles.map(role => [role, roleHost(state, role)])), startedAt: new Date().toISOString() };
|
|
897
903
|
const context = inlineContext(state);
|
|
898
904
|
state.wave.context = context;
|
|
899
905
|
try { save(state); } catch (error) { for (const lease of leases) releaseAgent(state.executionBudget, lease); throw error; }
|
|
900
906
|
const calls = roles.map(async (role, index) => {
|
|
901
907
|
const agent = hostAgent(state, role), started = Date.now();
|
|
902
|
-
|
|
908
|
+
const invocationId = invocationIdentity({ session_id: state.id, agent_id: state.wave.attemptIds[role] });
|
|
909
|
+
emit(state, { kind: 'agent-start', agent, invocation_id: invocationId });
|
|
903
910
|
let ok = false;
|
|
904
911
|
try {
|
|
905
912
|
const runner = hostRunner(state, role, runners);
|
|
@@ -907,12 +914,12 @@ export async function runParallelWave(state, { runners = { codex: runCodexExec,
|
|
|
907
914
|
const result = await observeControllerCall(state, { id: `${waveId}:${role}`, host: roleHost(state, role), role, kind: 'worker' }, () => runner({ prompt: workerHead(state, role) + context.text, cwd: state.root,
|
|
908
915
|
sandbox: 'read-only', ephemeral: true, timeoutMs: 300000,
|
|
909
916
|
bin: roleHost(state, role) === 'codex' ? process.env.GREAT_CTO_CODEX_BIN || 'codex' : process.env.GREAT_CTO_CLAUDE_BIN || 'claude',
|
|
910
|
-
extraArgs: roleHost(state, role) === 'codex' ? workerArgs : [], onEvent: toolListener(state, agent) }), save);
|
|
917
|
+
invocationId, extraArgs: roleHost(state, role) === 'codex' ? workerArgs : [], onEvent: toolListener(state, agent, invocationId) }), save);
|
|
911
918
|
ok = true;
|
|
912
919
|
return result;
|
|
913
920
|
} finally {
|
|
914
921
|
releaseAgent(state.executionBudget, leases[index]);
|
|
915
|
-
emit(state, { kind: 'agent-stop', agent, ok, duration_ms: Date.now() - started });
|
|
922
|
+
emit(state, { kind: 'agent-stop', agent, invocation_id: invocationId, ok, duration_ms: Date.now() - started });
|
|
916
923
|
}
|
|
917
924
|
});
|
|
918
925
|
const settled = await Promise.allSettled(calls);
|
|
@@ -956,6 +963,7 @@ export async function runParallelWave(state, { runners = { codex: runCodexExec,
|
|
|
956
963
|
save(state); return state;
|
|
957
964
|
}
|
|
958
965
|
await runStage(state, { prepared: { response: state.wave.responses[role], receipt: state.wave.receipt,
|
|
966
|
+
attemptId: state.wave.attemptIds?.[role],
|
|
959
967
|
context: state.wave.context, callId: `${state.wave.id}:${role}` }, runners, verify, checks, save, contextStore });
|
|
960
968
|
if (!state.results[role]) { state.wave.status = 'discarded'; break; } // Other snapshot is invalid.
|
|
961
969
|
}
|
|
@@ -14,9 +14,9 @@
|
|
|
14
14
|
* other plugins' agents (`feature-dev:code-reviewer`) record no verdict and
|
|
15
15
|
* were being asked for one.
|
|
16
16
|
*/
|
|
17
|
-
import { existsSync, readdirSync } from 'node:fs';
|
|
17
|
+
import { existsSync, readdirSync, mkdirSync } from 'node:fs';
|
|
18
18
|
import { homedir } from 'node:os';
|
|
19
|
-
import { dirname, join, resolve } from 'node:path';
|
|
19
|
+
import { dirname, join, resolve, sep } from 'node:path';
|
|
20
20
|
import { fileURLToPath } from 'node:url';
|
|
21
21
|
|
|
22
22
|
export function globalLayerDir(home = homedir()) {
|
|
@@ -38,6 +38,24 @@ export function isProjectState(dir, home = homedir()) {
|
|
|
38
38
|
return !isGlobalLayer(d, home) && (existsSync(join(d, 'PROJECT.md')) || existsSync(join(d, 'verdicts')));
|
|
39
39
|
}
|
|
40
40
|
|
|
41
|
+
/**
|
|
42
|
+
* Create a directory under a project's `.great_cto/`, and only there. Returns
|
|
43
|
+
* false — creating nothing — when the `.great_cto` it lives in is not a project
|
|
44
|
+
* (no PROJECT.md, no verdicts/), or is the global layer. Hooks fire in every
|
|
45
|
+
* repository a session opens; a plain mkdir wrote great_cto state into
|
|
46
|
+
* repositories that were not great_cto projects (great_cto-tng1).
|
|
47
|
+
*/
|
|
48
|
+
export function ensureStateDir(dir, home = homedir()) {
|
|
49
|
+
const abs = resolve(String(dir || ''));
|
|
50
|
+
const parts = abs.split(sep);
|
|
51
|
+
const i = parts.lastIndexOf('.great_cto');
|
|
52
|
+
if (i < 0) return false;
|
|
53
|
+
const gc = parts.slice(0, i + 1).join(sep) || sep;
|
|
54
|
+
if (!isProjectState(gc, home)) return false;
|
|
55
|
+
mkdirSync(abs, { recursive: true });
|
|
56
|
+
return true;
|
|
57
|
+
}
|
|
58
|
+
|
|
41
59
|
/** great_cto's agents, by name: the plugin's own agents/ directory (or GREAT_CTO_AGENTS_DIR). */
|
|
42
60
|
export function roster(dir = process.env.GREAT_CTO_AGENTS_DIR || resolve(dirname(fileURLToPath(import.meta.url)), '..', '..', 'agents')) {
|
|
43
61
|
try { return new Set(readdirSync(dir).filter((f) => f.endsWith('.md')).map((f) => f.slice(0, -3))); } catch { return new Set(); }
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
|
|
3
|
+
/** Host-provided identity, never inferred from a shared role log or session transcript. */
|
|
4
|
+
export function invocationIdentity(payload) {
|
|
5
|
+
const valid = (v) => typeof v === 'string' && /^[A-Za-z0-9_.:-]{1,160}$/.test(v);
|
|
6
|
+
if (!valid(payload?.agent_id) || !valid(payload?.session_id)) return null;
|
|
7
|
+
// Tuple encoding avoids ambiguous delimiters; a bounded digest fits marker
|
|
8
|
+
// filenames even when the provider supplies long identifiers.
|
|
9
|
+
return createHash('sha256').update(JSON.stringify([payload.session_id, payload.agent_id])).digest('hex');
|
|
10
|
+
}
|
|
@@ -21,6 +21,7 @@
|
|
|
21
21
|
* moved on and is reported as working. A `working` with a silent transcript for
|
|
22
22
|
* five minutes is `unknown`, not working (agtx uses the same bound).
|
|
23
23
|
*/
|
|
24
|
+
import { isProjectState } from './great-cto-scope.mjs';
|
|
24
25
|
import { mkdirSync, writeFileSync, readFileSync, readdirSync, statSync, rmSync, renameSync } from 'node:fs';
|
|
25
26
|
import { join } from 'node:path';
|
|
26
27
|
import { observeWorkSession } from './work-tasks.mjs';
|
|
@@ -47,7 +48,9 @@ export function recordHookEvent(payload, { now = Date.now() } = {}) {
|
|
|
47
48
|
if (event === 'SessionEnd') { rmSync(file, { force: true }); return { removed: true }; }
|
|
48
49
|
const state = EVENT_STATE[event];
|
|
49
50
|
if (!state) return null;
|
|
50
|
-
|
|
51
|
+
// A project, not merely a `.great_cto/` directory: an older SessionStart made
|
|
52
|
+
// one in any repository it ran in, and status then landed in someone else's tree.
|
|
53
|
+
if (!isProjectState(join(cwd, '.great_cto'))) return null;
|
|
51
54
|
const rec = {
|
|
52
55
|
session: id,
|
|
53
56
|
state,
|
package/package.json
CHANGED