Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion plugins/forge/.codex-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "forge",
"version": "1.20.0",
"version": "1.20.3",
"description": "Forge by ShipToday brings free, AI-powered product development lifecycle automation into Codex.",
"author": {
"name": "ShipToday",
Expand Down
52 changes: 52 additions & 0 deletions plugins/forge/hooks/checkpoint-claim.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
'use strict';

const fs = require('fs');
const { isDeepStrictEqual } = require('util');
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;

function matches(state, call) {
const sent = state.delivered_checkpoint;
const id = call.state_updates.codex_checkpoint_id;
if (!UUID_RE.test(String(id || '')) || id !== sent?.id ||
id !== state.checkpoint_delivery?.id ||
call.conversation_id !== sent.conversation_id ||
call.completed_step !== sent.completed_step) return false;
// The guard may refresh cumulative tokens; all other frozen fields must match.
const { token_usage: incomingTokens, ...incoming } = call.state_updates;
const { token_usage: queuedTokens, ...queued } = sent.state_updates;
return isDeepStrictEqual(incoming, queued);
}

function attemptPath(session, id) {
return `${session.stateFilePath}.${id}.attempt`;
}

// Exclusive creation is the cross-process claim. Atomic state replacement alone
// is not a lock: two PreToolUse processes could both read an unattempted receipt.
function claim(session, call) {
const state = session.read();
if (!matches(state, call)) return 'Unknown, stale, or modified passive checkpoint. Use only the current delivered payload; do not reconstruct or replay it.';
if (state.checkpoint_delivery.attempted_at || state.checkpoint_delivery.processed_at) {
return 'This passive checkpoint was already attempted or recorded. Do not retry it, even if the previous result is unknown.';
}
const id = call.state_updates.codex_checkpoint_id;
const at = new Date().toISOString();
try {
fs.writeFileSync(attemptPath(session, id), at, { encoding: 'utf8', flag: 'wx', mode: 0o600 });
const current = session.read();
if (!matches(current, call)) return 'The delivered checkpoint changed before submission. Do not retry this stale payload.';
session.write({ checkpoint_delivery: { ...current.checkpoint_delivery, attempted_at: at } });
return null;
} catch (error) {
return error.code === 'EEXIST'
? 'This passive checkpoint was already attempted. Do not retry an ambiguous submission.'
: 'Could not persist the passive checkpoint claim. Submission is blocked to prevent duplicate recording.';
}
}

function attempted(session, state, call) {
return matches(state, call) && !!state.checkpoint_delivery.attempted_at &&
fs.existsSync(attemptPath(session, call.state_updates.codex_checkpoint_id));
}

module.exports = { claim, attempted };
19 changes: 19 additions & 0 deletions plugins/forge/hooks/hook-input.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
'use strict';

// Normalize once before inspection or enrichment. Invalid input stays invalid;
// it must never be spread into character-indexed state or treated as completion.
function record(value) {
if (typeof value === 'string') {
try { value = JSON.parse(value); } catch { return null; }
}
return value && typeof value === 'object' && !Array.isArray(value) ? value : null;
}

function stateCall(input) {
const call = record(input);
if (!call) return null;
const updates = call.state_updates === undefined ? {} : record(call.state_updates);
return updates ? { ...call, state_updates: updates } : null;
}

module.exports = { record, stateCall };
12 changes: 8 additions & 4 deletions plugins/forge/hooks/must-display.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -301,20 +301,24 @@ async function main() {
return; // Malformed input — exit silently.
}

const sessionState = sessionStateModule.forSession(event.session_id);
// Codex: the dedup fingerprint lives in a sidecar next to the state file,
// not inside it. This hook and workflow-tracker.cjs fire on the SAME
// PostToolUse event, and two read-modify-write cycles on one file can lose
// updates. One writer per file.
const sidecar = `${sessionStateModule.forSession(event.session_id).stateFilePath}.display`;

let last = null;
try {
last = sessionState.read().last_display_fingerprint || null;
last = fs.readFileSync(sidecar, 'utf8').trim() || null;
} catch {
last = null; // A missing/corrupt state file must not suppress the display.
last = null; // A missing/corrupt sidecar must not suppress the display.
}

const result = decide(event, last);
if (!result.systemMessage) return;

try {
sessionState.write({ last_display_fingerprint: result.fingerprint });
fs.writeFileSync(sidecar, result.fingerprint, 'utf8');
} catch {
// Persisting dedup state is best-effort; showing the block is not.
}
Expand Down
109 changes: 109 additions & 0 deletions plugins/forge/hooks/passive-observation.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
#!/usr/bin/env node
'use strict';

// Codex-only delivery helpers. No network, no model turn, no implicit consent.
const path = require('path');
const sessionStateModule = require('./session-state.cjs');
const CHECKPOINT_INTERVAL = 8;
const DISPOSITIONS = ['observe', 'skip', 'defer', 'sleep'];
// Queued ids are always randomUUID() from stop-observer.cjs; anything else is
// dropped, never delivered.
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
// Arguments that reach the acknowledge command are validated first.
const SAFE_ARG_RE = /^[A-Za-z0-9._-]{1,128}$/;

function additionalContext(text) {
return JSON.stringify({ hookSpecificOutput: { hookEventName: 'UserPromptSubmit', additionalContext: text } });
}

function oneLine(text, max = 200) {
return String(text || '').replace(/[\r\n\t]+/g, ' ').replace(/\s+/g, ' ').trim().slice(0, max);
}

// The complete receipt command, built here from validated values so the model
// never assembles it from file contents. Only the final token is for the model.
function acknowledgeCommand(sessionId, id) {
if (!SAFE_ARG_RE.test(String(sessionId || '')) || !UUID_RE.test(String(id || ''))) return null;
return `node "${path.join(__dirname, 'passive-observation.cjs')}" acknowledge ${sessionId} ${id} <${DISPOSITIONS.join('|')}>`;
}

// Returns developer-context TEXT for this prompt, or ''. prompt-router.cjs
// joins it with any routing text and wraps the result once, so routing and
// passive delivery never compete for the same prompt.
function deliver(session, state, event) {
const at = new Date().toISOString();
if (state.active_workflow || state.pending_checkpoint) return '';
if (event.turn_id && state.last_passive_prompt_turn === event.turn_id) return '';
// A Forge clarification (start_workflow returned without a conversation) is
// being answered on the very next prompt. workflow-tracker.cjs stamps the
// turn; the window is one prompt wide by construction, so nothing has to
// clear it. (observer_blocked cannot carry this: prompt-router re-arms it
// before delivery runs.)
if (state.clarification_at_turn != null && (state.turn_count || 0) - state.clarification_at_turn <= 1) return '';
if (state.passive_checkpoint_due) {
const pending = state.passive_checkpoint_due;
if (!UUID_RE.test(String(pending.id || ''))) { session.write({ passive_checkpoint_due: null }); return ''; }
// Upgrade an undelivered legacy queue safely; already-delivered id-less
// payloads are rejected by the guard because they cannot prove identity.
pending.state_updates = { ...pending.state_updates, codex_checkpoint_id: pending.id };
session.write({ passive_checkpoint_due: null, delivered_checkpoint: pending,
last_passive_prompt_turn: event.turn_id || null,
skills_flushed_at_turn: pending.skills_through,
checkpoint_delivery: { id: pending.id, at, attempted_at: null, processed_at: null } });
return `FORGE PASSIVE CHECKPOINT: Read the existing forge-autopilot skill's ` +
`Codex passive delivery instructions. Session state: ${JSON.stringify(session.stateFilePath)}. ` +
`Process delivered_checkpoint ${pending.id} once in this active turn; preserve the user's substantive final answer.`;
}
if (state.status === 'dismissed' || state.status === 'linked' || state.status === 'logged' || state.forge_observation_enabled === false) {
if (state.observation_due) session.write({ observation_due: null });
return '';
}
const due = state.observation_due;
// A snoozed session is checked against its wake condition on every prompt
// (as in the Claude Code plugin); the eight-turn re-offer queued by stop-observer.cjs
// takes precedence on the prompt it lands on.
if (state.status === 'snoozed' && !due && state.wake_condition) {
return `FORGE ROUTING: The tracking offer in this session is snoozed. Wake condition: ` +
`"${oneLine(state.wake_condition)}". If the user's current message clearly satisfies it, invoke the ` +
`"forge-autopilot" skill via the Skill tool with the input "observe session — start the observe_session ` +
`workflow for passive tracking" after completing the user's request. Otherwise continue normally and ` +
`do NOT mention this check to the user.`;
}
if (!due) return '';
if (!UUID_RE.test(String(due.id || ''))) { session.write({ observation_due: null }); return ''; }
session.write({ observation_due: null, delivered_observation: due,
observer_fired: true, observer_blocked: true, last_observer_turn: state.turn_count,
last_passive_prompt_turn: event.turn_id || null,
observation_delivery: { id: due.id, at, processed_at: null, disposition: null } });
const command = acknowledgeCommand(event.session_id || state.session_id, due.id);
return `FORGE PASSIVE OBSERVATION: Read the existing forge-autopilot skill's ` +
`Codex passive delivery instructions. Session state: ${JSON.stringify(session.stateFilePath)}. ` +
`Evaluate delivered_observation ${due.id} once in this active turn; preserve the user's substantive final answer. ` +
(command
? `Record your evaluation by running exactly this command, replacing only the final token: ${command}`
: `No receipt command is available for this session; do not construct one.`);
}

// A local evaluation receipt distinguishes delivered context from context the
// model actually processed. It neither logs work remotely nor grants consent.
function acknowledge(sessionId, id, disposition) {
if (!SAFE_ARG_RE.test(String(sessionId || '')) || !UUID_RE.test(String(id || '')) ||
!DISPOSITIONS.includes(disposition)) return false;
const session = sessionStateModule.forSession(sessionId);
const state = session.read();
if (state.observation_delivery?.id !== id || state.observation_delivery.processed_at) return false;
const updates = { observation_delivery: { ...state.observation_delivery,
processed_at: new Date().toISOString(), disposition } };
// `defer` means "not now, but this session still wants the offer": re-arm the
// fire-once latch so stop-observer.cjs can queue it again after its cooldown.
// `skip` and `observe` keep the latch (asked and answered); `sleep` leaves a
// snoozed session on its own wake schedule.
if (disposition === 'defer') Object.assign(updates, { observer_fired: false, observer_blocked: false });
session.write(updates);
return true;
}

if (require.main === module) {
if (process.argv[2] !== 'acknowledge' || !acknowledge(...process.argv.slice(3))) process.exitCode = 1;
}
module.exports = { CHECKPOINT_INTERVAL, additionalContext, deliver, acknowledge, acknowledgeCommand };
97 changes: 29 additions & 68 deletions plugins/forge/hooks/prompt-router.cjs
Original file line number Diff line number Diff line change
@@ -1,48 +1,22 @@
#!/usr/bin/env node

/**
* prompt-router.js — UserPromptSubmit hook for the ShipToday Forge plugin.
*
* Mostly stateful routing — content-based pattern matching for SDLC
* vocabulary (PRD, story breakdown, tech handoff, etc.) has been removed.
* The LLM decides whether to invoke `forge-autopilot` for those cases
* via its SKILL.md description.
*
* The hook fires for two things the LLM cannot reliably decide on its own:
*
* 1. **Epic key references** (e.g. "explore architecture of PROJ-615").
* Skill discovery is a soft signal and Claude can choose to bypass
* Forge when it has alternative tools (Linear MCP, Read, Grep) that
* look usable. A regex match on a tracked work item id is a strong
* structural signal and gets an ADVISORY routing directive — a hint
* that surfaces the key and recommends Forge, but yields agency to
* Claude when the conversation context warrants a different route.
* The regex is purely structural — it knows nothing
* about workflows or skills, so adding new ones requires no changes
* here.
*
* 2. **Stateful routing** for things stored on disk by other hooks:
* - active workflow continuation (workflow-tracker writes this)
* - snoozed wake check (session_observer writes this)
*
* Execution order (first match wins):
* 0. Seed the git baseline (SHI-906) — silent, once per session, before
* any of the routing below and before this turn's work happens
* 1. Linked → silent (already tracked, no directive needed)
* 2. Active workflow → emit continuation directive
* 3. Epic key in prompt → emit epic-key routing directive
* 4. Snoozed → emit wake check
* 5. Otherwise → silent (let the LLM decide via SKILL.md description)
*
* @see plugin/hooks/session-state.cjs for state management
* @see plugin/hooks/stop-observer.cjs for passive observation
* @see plugin/skills/forge-autopilot/SKILL.md for routing logic
* Codex UserPromptSubmit routing. Required workflow continuation comes first.
* Explicit work-item routing and queued passive developer context are then
* delivered TOGETHER in one additionalContext payload, so a prompt that
* mentions a work item can never starve a queued checkpoint or offer. Linked
* sessions stay silent on key routing (as in the Claude Code plugin — the work
* is already tracked) but still receive their passive context. Stop schedules
* observation/checkpoints locally; a snoozed session gets a short per-prompt
* wake check from passive-observation.cjs.
* Preserve this Codex adaptation during plugin synchronization.
*/

'use strict';

const sessionStateModule = require('./session-state.cjs');
const { readHeadRef } = require('./git-head.cjs');
const { deliver, additionalContext } = require('./passive-observation.cjs');

// -- Detection patterns ------------------------------------------------------

Expand All @@ -53,26 +27,15 @@ const EPIC_KEY_RE = /\b[A-Z][A-Z0-9]+-\d+\b/;

// -- Directive builders ------------------------------------------------------

function emitWakeConditionCheck(wakeCondition) {
process.stdout.write(
`FORGE ROUTING: This session is snoozed. ` +
`Wake condition: "${wakeCondition}". ` +
`Evaluate if the user's current message signals readiness. ` +
`If yes, you MUST invoke the "forge-autopilot" skill via the Skill tool BEFORE responding ` +
`with the input: "observe session — start the observe_session workflow for passive tracking". ` +
`If no, continue normally and do NOT mention this check to the user.`
);
}

function emitEpicKeyRouting(key) {
function epicKeyRouting(key) {
// Advisory tone (was forced "MUST invoke"). The server now handles
// cited-reference disambiguation via `needsKeyConfirmation`,
// so the hook no longer needs to force the routing path. The hint
// remains because it's the structural signal that nudges Claude away
// from grabbing the work item directly via tracker MCP tools when
// Forge is the appropriate orchestrator — but final agency stays with
// Claude when the conversation context warrants a different choice.
process.stdout.write(
return (
`FORGE ROUTING (advisory): The user's message references work item "${key}". ` +
`Consider invoking the "forge-autopilot" skill via the Skill tool — Forge orchestrates ` +
`the SDLC actions (planning, implementation, review, status) for tracked work items, ` +
Expand Down Expand Up @@ -169,39 +132,37 @@ async function main() {
state.observer_blocked = false; // keep local copy in sync for downstream checks
}

// Step 1: Linked sessions need no directives — already tracked
if (state.status === 'linked') return;

// Step 2: Active workflow → tell Claude to continue, not start fresh
if (state.active_workflow) {
emitWorkflowContinuation(state.conversation_id, state.current_skill);
return;
}

// Step 3: Epic key in prompt → forced routing directive (wins over
// snoozed/dismissed because the user is explicitly referencing tracked work).
// This is the only content-based signal the hook acts on. It catches the
// case where Claude would otherwise bypass Forge in favor of fetching the
// work item directly via Linear/Jira/etc.
if (prompt) {
const parts = [];

// Step 3: Epic key in prompt → advisory routing directive. This is the only
// content-based signal the hook acts on. It catches the case where Claude
// would otherwise bypass Forge in favor of fetching the work item directly
// via Linear/Jira/etc. A linked session is already tracked, so it gets no
// nudge (as in the Claude Code plugin) — its passive context still flows.
if (prompt && state.status !== 'linked') {
const keyMatch = prompt.match(EPIC_KEY_RE);
if (keyMatch) {
sessionState.write({ routing_emitted: true });
emitEpicKeyRouting(keyMatch[0]);
return;
parts.push(epicKeyRouting(keyMatch[0]));
}
}

// Step 4: Snoozed → ask Claude to re-evaluate against the wake condition
if (state.status === 'snoozed') {
const wake = state.wake_condition || 'user signals readiness to move forward';
emitWakeConditionCheck(wake);
return;
}
// Codex localization: Stop only queues work. Deliver it once as developer
// context on a real prompt — alongside routing, never instead of it, so a
// work-item mention can't hold back a queued checkpoint whose time is
// already reserved.
const passive = deliver(sessionState, state, event);
if (passive) parts.push(passive);

// Step 5: No state worth acting on → silent. The LLM reads the
// forge-autopilot SKILL.md description and decides whether to invoke
// it. stop-observer.cjs handles passive observation after the response.
// forge-autopilot SKILL.md description and decides whether to invoke it.
if (parts.length) process.stdout.write(additionalContext(parts.join('\n\n')));
}

main().catch(() => {
Expand Down
Loading