forgeos 0.1.0-alpha.20 → 0.1.0-alpha.22

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.
Files changed (53) hide show
  1. package/AGENTS.md +1 -1
  2. package/CHANGELOG.md +29 -2
  3. package/README.md +1 -1
  4. package/adapters/java/target/forge-java-adapter-0.1.0-alpha.11.jar +0 -0
  5. package/adapters/java-spring-boot-starter/target/forge-java-spring-boot-starter-0.1.0-alpha.11.jar +0 -0
  6. package/docs/changelog.md +43 -0
  7. package/examples/java-billing/target/java-billing-0.1.0-alpha.11-all.jar +0 -0
  8. package/examples/java-billing/target/java-billing-0.1.0-alpha.11.jar +0 -0
  9. package/package.json +1 -1
  10. package/src/forge/_generated/releaseManifest.json +1 -1
  11. package/src/forge/_generated/releaseManifest.ts +3 -3
  12. package/src/forge/agent-adapters/types.ts +3 -0
  13. package/src/forge/agent-memory/bridge.ts +69 -4
  14. package/src/forge/agent-memory/context-pack.ts +106 -8
  15. package/src/forge/agent-memory/sources/codex-hook-runner.mjs +191 -2
  16. package/src/forge/agent-memory/types.ts +4 -1
  17. package/src/forge/brownfield-import/index.ts +68 -3
  18. package/src/forge/brownfield-import/types.ts +1 -1
  19. package/src/forge/cli/commands.ts +47 -0
  20. package/src/forge/cli/main.ts +4 -0
  21. package/src/forge/cli/new.ts +3 -1
  22. package/src/forge/cli/parse.ts +64 -11
  23. package/src/forge/cli/studio.ts +54 -0
  24. package/src/forge/cli/verify.ts +2 -0
  25. package/src/forge/compiler/frontend-graph/build.ts +58 -2
  26. package/src/forge/delta/index.ts +12 -0
  27. package/src/forge/delta/recorder.ts +60 -0
  28. package/src/forge/delta/status.ts +639 -2
  29. package/src/forge/delta/store.ts +204 -5
  30. package/src/forge/delta/timeline.ts +75 -1
  31. package/src/forge/version.ts +1 -1
  32. package/templates/nuxt-web/.vscode/settings.json +14 -0
  33. package/templates/nuxt-web/README.md +30 -0
  34. package/templates/nuxt-web/forge.config.ts +3 -0
  35. package/templates/nuxt-web/package.json +33 -0
  36. package/templates/nuxt-web/src/actions/logNoteCreated.ts +11 -0
  37. package/templates/nuxt-web/src/commands/createNote.ts +26 -0
  38. package/templates/nuxt-web/src/forge/schema.ts +12 -0
  39. package/templates/nuxt-web/src/policies.ts +6 -0
  40. package/templates/nuxt-web/src/queries/listNotes.ts +8 -0
  41. package/templates/nuxt-web/src/queries/liveNotes.ts +8 -0
  42. package/templates/nuxt-web/tsconfig.json +17 -0
  43. package/templates/nuxt-web/web/app.vue +67 -0
  44. package/templates/nuxt-web/web/components/LiveNotes.vue +89 -0
  45. package/templates/nuxt-web/web/components/NoteComposer.vue +100 -0
  46. package/templates/nuxt-web/web/composables/forge.ts +13 -0
  47. package/templates/nuxt-web/web/composables/useNotes.ts +24 -0
  48. package/templates/nuxt-web/web/nuxt.config.ts +11 -0
  49. package/templates/nuxt-web/web/package.json +17 -0
  50. package/templates/nuxt-web/web/plugins/forge.client.ts +10 -0
  51. package/templates/nuxt-web/web/plugins/forge.server.ts +10 -0
  52. package/templates/nuxt-web/web/server/api/forge-health.get.ts +7 -0
  53. package/templates/nuxt-web/web/tsconfig.json +3 -0
package/AGENTS.md CHANGED
@@ -1,4 +1,4 @@
1
- // @forge-generated generator=0.1.0-alpha.20 input=a0a760df40f02ebbcf3138bab4133a51f5a69548cba44b7ee4ec711ce57af5c6 content=0d493cf0e41b71cb652d5e0e1b0c1f83d2a1281b748321f0b00f0773ba93074e
1
+ // @forge-generated generator=0.1.0-alpha.22 input=d8837238db9cf8868eceae3e3cc5a7d9bb46a0955c55f0143caa802597d5a3a5 content=0d493cf0e41b71cb652d5e0e1b0c1f83d2a1281b748321f0b00f0773ba93074e
2
2
  # AGENTS.md
3
3
 
4
4
  <!-- forge-generated:start -->
package/CHANGELOG.md CHANGED
@@ -1,5 +1,34 @@
1
1
  # forgeos
2
2
 
3
+ ## Unreleased
4
+
5
+ ## 0.1.0-alpha.22
6
+
7
+ ### Patch Changes
8
+
9
+ - Improve the post-alpha.21 agent workflow without adding new MCP tools.
10
+
11
+ - Add `forge agent context` scopes for entry, change, proof, and handoff context packs.
12
+ - Add DeltaDB verbose health details for queue redaction, operation age, semantic projection state, and overhead posture.
13
+ - Add `forge delta compact`, `forge delta prune`, and redacted `forge delta export` for local Delta maintenance and support bundles.
14
+ - Add `forge doctor delta` for recorder writability, queue drain, redaction, and gitignore checks.
15
+ - Add Semantic Timeline summary data for stale proofs and causal chains.
16
+ - Record CAIR snapshot/query/action activity as Delta timeline events without adding new MCP tools.
17
+ - Add a Studio snapshot handoff block and a dedicated CAIR Protocol documentation page.
18
+ - Add an official `nuxt-web` template with a Forge notes backend, client/server Nuxt plugins, a `useNotes` composable, a Nitro runtime-config route, and generated Vue composables.
19
+
20
+ ## 0.1.0-alpha.21
21
+
22
+ ### Patch Changes
23
+
24
+ - Harden Codex hook queue privacy and brownfield import classification.
25
+
26
+ - Queue new Codex hook events as redacted payloads instead of storing raw prompts, tool inputs, tool responses, or transcripts in `.forge/agent/events.ndjson`.
27
+ - Compact consumed hook queue history into redacted `.history` lines so old raw queue entries are not copied forward during drain retention.
28
+ - Scope brownfield route classification to the detected route handler, so read-only GET handlers are not marked command-like because a sibling route in the same file writes state.
29
+ - Mark read-shaped `POST /search`, `/query`, `/filter`, `/lookup`, and `/graphql` routes as `command-candidate` with `ambiguous-post-query` risk instead of treating them as normal writes.
30
+ - Sync the public docs changelog/CLI reference and clarify the alpha/latest npm dist-tag policy.
31
+
3
32
  ## 0.1.0-alpha.20
4
33
 
5
34
  ### Patch Changes
@@ -11,8 +40,6 @@
11
40
  - Preserve empty stdio command arguments, diagnose malformed command strings, and support structured `service.commandArgs` in external manifests.
12
41
  - Include the basic example client demo in typecheck coverage.
13
42
 
14
- ## Unreleased
15
-
16
43
  ## 0.1.0-alpha.19
17
44
 
18
45
  Alpha hardening:
package/README.md CHANGED
@@ -413,7 +413,7 @@ Configure npm Trusted Publisher for package `forgeos`:
413
413
  | Environment | blank |
414
414
  | Allowed action | `npm publish` |
415
415
 
416
- Do not add `NPM_TOKEN` for normal releases. Alpha releases publish with the `alpha` dist-tag so prerelease builds do not become `latest` accidentally. Use `release:publish-local-alpha -- --dry-run` only to validate the staged tarball locally; real npm publishing should go through `release:publish-alpha`, which dispatches `publish.yml` and uses npm OIDC Trusted Publisher. The workflow checks whether the package version already exists before installing dependencies or running tests, then uses `id-token: write`, Node 24/npm 11+, and provenance for the actual publish. `npm run release:smoke` runs `npm pack`, creates a fresh app with the packed tarball, installs dependencies, runs `forge dev --once --json`, and verifies the app smoke path.
416
+ Do not add `NPM_TOKEN` for normal alpha publishes. Alpha releases publish with the `alpha` dist-tag through npm OIDC Trusted Publisher so prerelease builds do not become `latest` accidentally. Configure `NPM_TOKEN` only when maintainers intentionally want the workflow to promote `latest` with `npm dist-tag add forgeos@<version> latest`; otherwise that step is skipped and `latest` may lag behind `alpha` during hardening. Use `release:publish-local-alpha -- --dry-run` only to validate the staged tarball locally; real npm publishing should go through `release:publish-alpha`, which dispatches `publish.yml` and uses npm OIDC Trusted Publisher. The workflow checks whether the package version already exists before installing dependencies or running tests, then uses `id-token: write`, Node 24/npm 11+, and provenance for the actual publish. `npm run release:smoke` runs `npm pack`, creates a fresh app with the packed tarball, installs dependencies, runs `forge dev --once --json`, and verifies the app smoke path.
417
417
 
418
418
  ## Milestone History
419
419
 
package/docs/changelog.md CHANGED
@@ -6,6 +6,49 @@ The canonical source file in the repository is `CHANGELOG.md`.
6
6
 
7
7
  ## Unreleased
8
8
 
9
+ ## 0.1.0-alpha.22
10
+
11
+ - Added focused post-alpha.21 workflow improvements without expanding MCP tools:
12
+ scoped Agent Memory context packs, DeltaDB verbose health details, Semantic
13
+ Timeline stale-proof/causal summaries, Studio snapshot handoff metadata,
14
+ local Delta maintenance commands (`compact`, `prune`, redacted `export`),
15
+ `forge doctor delta`, CAIR timeline events, and a dedicated CAIR Protocol
16
+ documentation page.
17
+ - Added an official `nuxt-web` template: a Forge notes backend plus Nuxt app
18
+ using client/server Forge plugins, `web/composables/useNotes.ts`, generated
19
+ Vue composables, a Nitro runtime-config route, and
20
+ `NUXT_PUBLIC_FORGE_URL`.
21
+
22
+ ## 0.1.0-alpha.21
23
+
24
+ Alpha.21 hardens external-agent privacy and brownfield import polish:
25
+
26
+ - Codex hook runner queue entries now store redacted payloads instead of raw
27
+ prompts, tool inputs, tool responses, or transcripts.
28
+ - Consumed hook queue history is compacted as redacted `.history` entries, so
29
+ old raw queue lines are not copied forward during retention.
30
+ - Brownfield import now scopes write/side-effect heuristics to the detected
31
+ route handler when possible, preventing sibling mutating routes from making a
32
+ read-only GET route look command-like.
33
+ - Read-shaped `POST /search`, `/query`, `/filter`, `/lookup`, and `/graphql`
34
+ routes are emitted as `command-candidate` with `ambiguous-post-query` risk
35
+ until a human review decides whether they should become Forge queries or
36
+ commands.
37
+ - CLI/reference docs now include the CAIR agent protocol and clarify the
38
+ `alpha`/`latest` npm dist-tag policy.
39
+
40
+ ## 0.1.0-alpha.20
41
+
42
+ Generated-change and hook queue fixes:
43
+
44
+ - Fixed generated-change diagnostics for `AGENTS.md` generated blocks and
45
+ `.forge/agent/context.json`.
46
+ - Skipped probe, invalid, and out-of-workspace queued hook events during Agent
47
+ Memory drain, and bounded large hook queue inspection.
48
+ - Preserved empty stdio command arguments, diagnosed malformed command strings,
49
+ and supported structured `service.commandArgs` in external manifests.
50
+ - Included the basic example client demo in typecheck coverage.
51
+
9
52
  ## 0.1.0-alpha.19
10
53
 
11
54
  Alpha hardening:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "forgeos",
3
- "version": "0.1.0-alpha.20",
3
+ "version": "0.1.0-alpha.22",
4
4
  "description": "Agent-native application framework and compiler for building Forge apps without a mandatory dashboard.",
5
5
  "type": "module",
6
6
  "files": [
@@ -1 +1 @@
1
- {"defaultProvider":"local","diagnostics":[],"env":{"deployEnv":"FORGE_DEPLOY_ENV","deployId":"FORGE_DEPLOY_ID","publicReleaseId":"NEXT_PUBLIC_FORGE_RELEASE_ID","releaseId":"FORGE_RELEASE_ID"},"gitSha":"unknown","optionalProviders":["local","sentry-compatible","sentry","glitchtip","bugsink","otel","custom"],"packageName":"forgeos","packageVersion":"0.1.0-alpha.20","releaseId":"forgeos@0.1.0-alpha.20+unknown","schemaVersion":"0.1.0"}
1
+ {"defaultProvider":"local","diagnostics":[],"env":{"deployEnv":"FORGE_DEPLOY_ENV","deployId":"FORGE_DEPLOY_ID","publicReleaseId":"NEXT_PUBLIC_FORGE_RELEASE_ID","releaseId":"FORGE_RELEASE_ID"},"gitSha":"unknown","optionalProviders":["local","sentry-compatible","sentry","glitchtip","bugsink","otel","custom"],"packageName":"forgeos","packageVersion":"0.1.0-alpha.22","releaseId":"forgeos@0.1.0-alpha.22+unknown","schemaVersion":"0.1.0"}
@@ -1,4 +1,4 @@
1
- // @forge-generated generator=0.1.0-alpha.20 input=a0a760df40f02ebbcf3138bab4133a51f5a69548cba44b7ee4ec711ce57af5c6 content=3c1af03eecebf9f9b009d8a4a2e08079c9853fca2c742eed3ea3d5b8b68a57b7
1
+ // @forge-generated generator=0.1.0-alpha.22 input=d8837238db9cf8868eceae3e3cc5a7d9bb46a0955c55f0143caa802597d5a3a5 content=bb05ee3635ddeea2d84168f8bf728e07f82e896061e8494b17f56dea26000770
2
2
  export const releaseManifest = {
3
3
  "defaultProvider": "local",
4
4
  "diagnostics": [],
@@ -19,7 +19,7 @@ export const releaseManifest = {
19
19
  "custom"
20
20
  ],
21
21
  "packageName": "forgeos",
22
- "packageVersion": "0.1.0-alpha.20",
23
- "releaseId": "forgeos@0.1.0-alpha.20+unknown",
22
+ "packageVersion": "0.1.0-alpha.22",
23
+ "releaseId": "forgeos@0.1.0-alpha.22+unknown",
24
24
  "schemaVersion": "0.1.0"
25
25
  } as const;
@@ -34,6 +34,9 @@ export interface AgentCommandOptions {
34
34
  hookAction?: "smoke" | "status" | string;
35
35
  input?: unknown;
36
36
  entry?: string;
37
+ change?: string;
38
+ proof?: string;
39
+ handoff?: boolean;
37
40
  current?: boolean;
38
41
  limit?: number;
39
42
  watch?: boolean;
@@ -4,6 +4,7 @@ import { createDiagnostic } from "../compiler/diagnostics/create.ts";
4
4
  import { createDeltaId } from "../delta/ids.ts";
5
5
  import { DeltaStore, DeltaStoreBusyError, describeDeltaStoreBusy, summarizeDeltaStoreBusy } from "../delta/store.ts";
6
6
  import { extractAgentEventBindings, normalizeAgentEvent, summarizeAgentEvent } from "./normalize.ts";
7
+ import { redactAgentPayload } from "./redaction.ts";
7
8
  import { buildAgentMemoryContext } from "./context-pack.ts";
8
9
  import { claudeCodeInstallFiles, claudeCodeInstallResult } from "./sources/claude-code.ts";
9
10
  import { codexInstallFiles, codexInstallResult, privacyDefaults } from "./sources/codex.ts";
@@ -28,6 +29,9 @@ export interface AgentMemoryCommandOptions {
28
29
  eventName?: string;
29
30
  input?: unknown;
30
31
  entry?: string;
32
+ change?: string;
33
+ proof?: string;
34
+ handoff?: boolean;
31
35
  current?: boolean;
32
36
  dryRun?: boolean;
33
37
  force?: boolean;
@@ -307,6 +311,9 @@ export async function runAgentMemoryCommand(options: AgentMemoryCommandOptions):
307
311
  return await buildAgentMemoryContext({
308
312
  workspaceRoot: options.workspaceRoot,
309
313
  entry: options.entry,
314
+ change: options.change,
315
+ proof: options.proof,
316
+ handoff: options.handoff,
310
317
  limit: options.limit,
311
318
  });
312
319
  } catch (error) {
@@ -497,15 +504,62 @@ function compactAgentMemoryQueueFile(options: {
497
504
  }
498
505
  mkdirSync(dirname(historyFile), { recursive: true });
499
506
  const existingHistory = existsSync(historyFile) ? readFileSync(historyFile) : Buffer.alloc(0);
507
+ const redactedConsumedHistory = redactedQueueHistoryBuffer(originalConsumed);
500
508
  writeFileSync(
501
509
  historyFile,
502
- trimBufferStart(Buffer.concat([existingHistory, originalConsumed]), options.historyMaxBytes),
510
+ trimBufferStart(Buffer.concat([existingHistory, redactedConsumedHistory]), options.historyMaxBytes),
503
511
  );
504
512
  writeFileSync(options.watchFile, currentBuffer.subarray(options.consumedOffset));
505
513
  writeQueueCheckpoint(options.watchFile, 0);
506
514
  return { compacted: true, historyFile };
507
515
  }
508
516
 
517
+ function redactedQueueHistoryBuffer(consumedBuffer: Buffer): Buffer {
518
+ const { complete } = splitCompleteJsonLines(consumedBuffer);
519
+ const lines: string[] = [];
520
+ for (const line of complete) {
521
+ if (!line.raw.trim()) {
522
+ continue;
523
+ }
524
+ const parsed = normalizeRawInput(line.raw);
525
+ if (!parsed) {
526
+ lines.push(JSON.stringify({
527
+ forgeHookQueueV1: true,
528
+ historyRedacted: true,
529
+ rawStored: false,
530
+ payloadRedacted: true,
531
+ payload: { _parseError: true },
532
+ }));
533
+ continue;
534
+ }
535
+ lines.push(JSON.stringify(redactedQueueHistoryEntry(parsed)));
536
+ }
537
+ return Buffer.from(lines.length > 0 ? `${lines.join("\n")}\n` : "", "utf8");
538
+ }
539
+
540
+ function redactedQueueHistoryEntry(parsed: Record<string, unknown>): Record<string, unknown> {
541
+ if (parsed.forgeHookQueueV1 !== true) {
542
+ return {
543
+ historyRedacted: true,
544
+ rawStored: false,
545
+ payloadRedacted: true,
546
+ payload: redactAgentPayload(parsed).value,
547
+ };
548
+ }
549
+ const queuedPayload = objectField(parsed, "payload") ?? objectField(parsed, "raw") ?? {};
550
+ return {
551
+ forgeHookQueueV1: true,
552
+ source: typeof parsed.source === "string" ? parsed.source : "codex",
553
+ eventName: typeof parsed.eventName === "string" ? parsed.eventName : undefined,
554
+ workspaceRoot: typeof parsed.workspaceRoot === "string" ? parsed.workspaceRoot : undefined,
555
+ enqueuedAt: typeof parsed.enqueuedAt === "string" ? parsed.enqueuedAt : undefined,
556
+ historyRedacted: true,
557
+ rawStored: false,
558
+ payloadRedacted: true,
559
+ payload: parsed.payloadRedacted === true ? queuedPayload : redactAgentPayload(queuedPayload).value,
560
+ };
561
+ }
562
+
509
563
  function splitCompleteJsonLines(buffer: Buffer): {
510
564
  complete: Array<{ raw: string; endOffset: number }>;
511
565
  completeBytes: number;
@@ -1004,6 +1058,12 @@ function formatAgentMemoryContextHuman(result: AgentMemoryContextPack): string {
1004
1058
  lines.push(` - ${question}`);
1005
1059
  }
1006
1060
  }
1061
+ if (result.recommendedCommands.length > 0) {
1062
+ lines.push("", "Next:");
1063
+ for (const command of result.recommendedCommands.slice(0, 6)) {
1064
+ lines.push(` ${command}`);
1065
+ }
1066
+ }
1007
1067
  return `${lines.join("\n")}\n`;
1008
1068
  }
1009
1069
 
@@ -1104,6 +1164,11 @@ function normalizeRawInput(input: unknown): Record<string, unknown> | null {
1104
1164
  return null;
1105
1165
  }
1106
1166
 
1167
+ function objectField(value: Record<string, unknown>, key: string): Record<string, unknown> | undefined {
1168
+ const child = value[key];
1169
+ return child && typeof child === "object" && !Array.isArray(child) ? child as Record<string, unknown> : undefined;
1170
+ }
1171
+
1107
1172
  export async function readStdinJson(options?: { timeoutMs?: number }): Promise<unknown> {
1108
1173
  if (process.stdin.isTTY) {
1109
1174
  return undefined;
@@ -1150,15 +1215,15 @@ function parseQueuedHookLine(raw: Record<string, unknown>): {
1150
1215
  if (raw.forgeHookQueueV1 !== true) {
1151
1216
  return null;
1152
1217
  }
1153
- const payload = raw.raw;
1154
- if (!payload || typeof payload !== "object" || Array.isArray(payload)) {
1218
+ const payload = objectField(raw, "payload") ?? objectField(raw, "raw");
1219
+ if (!payload) {
1155
1220
  return null;
1156
1221
  }
1157
1222
  return {
1158
1223
  source: typeof raw.source === "string" ? raw.source : "codex",
1159
1224
  eventName: typeof raw.eventName === "string" ? raw.eventName : undefined,
1160
1225
  workspaceRoot: typeof raw.workspaceRoot === "string" ? raw.workspaceRoot : undefined,
1161
- payload: payload as Record<string, unknown>,
1226
+ payload,
1162
1227
  };
1163
1228
  }
1164
1229
 
@@ -4,14 +4,26 @@ import type { AgentMemoryContextEvent, AgentMemoryContextPack, AgentMemoryEventR
4
4
  export async function buildAgentMemoryContext(input: {
5
5
  workspaceRoot: string;
6
6
  entry?: string;
7
+ change?: string;
8
+ proof?: string;
9
+ handoff?: boolean;
7
10
  limit?: number;
8
11
  }): Promise<AgentMemoryContextPack> {
9
12
  const store = await DeltaStore.open(input.workspaceRoot, { access: "read" });
10
13
  try {
11
- const target = input.entry;
12
- const events = await store.listAgentMemoryEvents({ target, limit: input.limit ?? 50 });
13
- const timeline = target ? await store.semanticTimeline({ target, limit: input.limit ?? 50 }) : undefined;
14
- const current = target ? timeline?.currentState ?? {} : await currentSessionState(store);
14
+ const scope = contextScope(input);
15
+ const target = contextTarget(input, scope);
16
+ const currentSession = await store.currentWorkSession();
17
+ const sessionId = scope === "change" && (input.change === "current" || !input.change)
18
+ ? currentSession?.id
19
+ : undefined;
20
+ const events = await store.listAgentMemoryEvents({ target: eventTarget(input, scope), limit: input.limit ?? 50 });
21
+ const timeline = target || sessionId
22
+ ? await store.semanticTimeline({ target, workSessionId: sessionId, limit: input.limit ?? 50 })
23
+ : undefined;
24
+ const current = timeline?.currentState && Object.keys(timeline.currentState).length > 0
25
+ ? timeline.currentState
26
+ : await currentSessionState(store, currentSession);
15
27
  const goals = events
16
28
  .filter((event) => event.normalizedKind === "agent.prompt.submitted")
17
29
  .map((event) => ({
@@ -35,9 +47,12 @@ export async function buildAgentMemoryContext(input: {
35
47
  const openQuestions = timeline?.openQuestions ?? [];
36
48
  return {
37
49
  ok: true,
38
- scope: target ? "entry" : "current",
39
- entry: target,
50
+ scope,
51
+ entry: input.entry,
52
+ change: input.change,
53
+ proof: input.proof,
40
54
  currentState: current,
55
+ recommendedCommands: recommendedCommands(scope, input, currentSession?.id),
41
56
  agentMemory: {
42
57
  summary: {
43
58
  events: contextEventItems.length,
@@ -68,6 +83,84 @@ export async function buildAgentMemoryContext(input: {
68
83
  }
69
84
  }
70
85
 
86
+ function contextScope(input: {
87
+ entry?: string;
88
+ change?: string;
89
+ proof?: string;
90
+ handoff?: boolean;
91
+ }): AgentMemoryContextPack["scope"] {
92
+ if (input.handoff) {
93
+ return "handoff";
94
+ }
95
+ if (input.proof) {
96
+ return "proof";
97
+ }
98
+ if (input.change) {
99
+ return "change";
100
+ }
101
+ return input.entry ? "entry" : "current";
102
+ }
103
+
104
+ function contextTarget(
105
+ input: { entry?: string; change?: string; proof?: string },
106
+ scope: AgentMemoryContextPack["scope"],
107
+ ): string | undefined {
108
+ if (scope === "entry") {
109
+ return input.entry;
110
+ }
111
+ if (scope === "proof") {
112
+ return input.proof?.includes(":") ? input.proof : `proof:${input.proof}`;
113
+ }
114
+ if (scope === "change" && input.change && input.change !== "current") {
115
+ return input.change.includes(":") ? input.change : `session:${input.change}`;
116
+ }
117
+ return undefined;
118
+ }
119
+
120
+ function eventTarget(
121
+ input: { entry?: string; change?: string; proof?: string },
122
+ scope: AgentMemoryContextPack["scope"],
123
+ ): string | undefined {
124
+ if (scope === "entry") {
125
+ return input.entry;
126
+ }
127
+ if (scope === "proof") {
128
+ return input.proof;
129
+ }
130
+ if (scope === "change" && input.change !== "current") {
131
+ return input.change;
132
+ }
133
+ return undefined;
134
+ }
135
+
136
+ function recommendedCommands(
137
+ scope: AgentMemoryContextPack["scope"],
138
+ input: { entry?: string; change?: string; proof?: string },
139
+ currentSessionId: string | undefined,
140
+ ): string[] {
141
+ const commands = [
142
+ "forge agent timeline --json",
143
+ "forge delta status --verbose --json",
144
+ ];
145
+ if (scope === "entry" && input.entry) {
146
+ commands.push(`forge timeline ${input.entry} --json`);
147
+ commands.push(`forge explain ${input.entry} --json`);
148
+ }
149
+ if (scope === "proof" && input.proof) {
150
+ commands.push(`forge timeline proof:${input.proof.replace(/^proof:/u, "")} --json`);
151
+ }
152
+ if (scope === "change") {
153
+ commands.push(`forge timeline --session ${input.change === "current" || !input.change ? "current" : input.change} --json`);
154
+ commands.push("forge changed --json");
155
+ }
156
+ if (scope === "handoff") {
157
+ commands.push("forge handoff --json");
158
+ commands.push(`forge timeline --session ${currentSessionId ?? "current"} --json`);
159
+ commands.push("forge changed --json");
160
+ }
161
+ return [...new Set(commands)];
162
+ }
163
+
71
164
  function contextEvents(events: AgentMemoryEventRecord[]): AgentMemoryContextEvent[] {
72
165
  return events.map((event) => {
73
166
  const eventBindings = bindings(event);
@@ -92,8 +185,8 @@ function contextEvents(events: AgentMemoryEventRecord[]): AgentMemoryContextEven
92
185
  });
93
186
  }
94
187
 
95
- async function currentSessionState(store: DeltaStore): Promise<Record<string, unknown>> {
96
- const session = await store.currentWorkSession();
188
+ async function currentSessionState(store: DeltaStore, currentSession?: Awaited<ReturnType<DeltaStore["currentWorkSession"]>>): Promise<Record<string, unknown>> {
189
+ const session = currentSession ?? await store.currentWorkSession();
97
190
  return session
98
191
  ? {
99
192
  sessionId: session.id,
@@ -101,6 +194,11 @@ async function currentSessionState(store: DeltaStore): Promise<Record<string, un
101
194
  inferredIntent: session.inferredIntent,
102
195
  confidence: session.confidence,
103
196
  status: session.status,
197
+ reasons: session.reasons.slice(0, 5).map((reason) => ({
198
+ signal: reason.signal,
199
+ weight: reason.weight,
200
+ ...(reason.value ? { value: reason.value } : {}),
201
+ })),
104
202
  }
105
203
  : {};
106
204
  }
@@ -1,8 +1,9 @@
1
1
  #!/usr/bin/env node
2
2
  /**
3
3
  * Lightweight Codex hook runner — no Forge CLI, no DeltaDB.
4
- * Reads stdin with a short timeout, enqueues to .forge/agent/events.ndjson, exits.
4
+ * Reads stdin with a short timeout, enqueues a redacted event to .forge/agent/events.ndjson, exits.
5
5
  */
6
+ import { createHash } from "node:crypto";
6
7
  import { appendFileSync, mkdirSync } from "node:fs";
7
8
  import { dirname, join, resolve } from "node:path";
8
9
 
@@ -17,6 +18,24 @@ if (!eventName) {
17
18
  const workspaceRoot = resolve(process.cwd());
18
19
  const eventsFile = join(workspaceRoot, ".forge", "agent", "events.ndjson");
19
20
 
21
+ const RAW_TEXT_KEYS = new Set([
22
+ "prompt",
23
+ "userPrompt",
24
+ "last_assistant_message",
25
+ "lastAssistantMessage",
26
+ "completion",
27
+ "message",
28
+ "transcript",
29
+ "transcript_path",
30
+ "transcriptPath",
31
+ "output",
32
+ "stdout",
33
+ "stderr",
34
+ "result",
35
+ ]);
36
+
37
+ const RAW_ARGS_KEYS = new Set(["args", "arguments", "tool_input", "toolInput", "tool_response", "toolResponse", "input"]);
38
+
20
39
  function readStdin(timeoutMs) {
21
40
  return new Promise((resolveRead) => {
22
41
  if (process.stdin.isTTY) {
@@ -72,13 +91,183 @@ async function main() {
72
91
  eventName,
73
92
  workspaceRoot,
74
93
  enqueuedAt: new Date().toISOString(),
75
- raw,
94
+ rawStored: false,
95
+ payloadRedacted: true,
96
+ payload: sanitizePayload(raw, eventName),
76
97
  };
77
98
 
78
99
  mkdirSync(dirname(eventsFile), { recursive: true });
79
100
  appendFileSync(eventsFile, `${JSON.stringify(entry)}\n`, "utf8");
80
101
  }
81
102
 
103
+ function sanitizePayload(raw, hookEventName) {
104
+ const payload = stripRawPayload(raw);
105
+ if (!payload.hook_event_name) {
106
+ payload.hook_event_name = hookEventName;
107
+ }
108
+ if (!payload.cwd) {
109
+ payload.cwd = workspaceRoot;
110
+ }
111
+
112
+ const toolInput = objectField(raw, "tool_input") ?? objectField(raw, "toolInput");
113
+ const toolResponse = objectField(raw, "tool_response") ?? objectField(raw, "toolResponse");
114
+ const command = stringField(toolInput, "command") ?? stringField(raw, "command");
115
+ if (command) {
116
+ payload.commandHash = hashStable(command);
117
+ payload.commandStored = false;
118
+ payload.commandSummary = summarizeCommand(command);
119
+ payload.commandKind = classifyCommand(stringField(raw, "tool_name") ?? stringField(raw, "toolName"), command);
120
+ }
121
+
122
+ const description = stringField(toolInput, "description");
123
+ if (description) {
124
+ payload.approvalDescriptionSummary = safeSummary(description, 180);
125
+ }
126
+
127
+ const exitCode = numberField(toolResponse, "exitCode") ?? numberField(toolResponse, "exit_code") ??
128
+ numberField(raw, "exitCode") ?? numberField(raw, "exit_code");
129
+ if (exitCode !== undefined) {
130
+ payload.exitCode = exitCode;
131
+ payload.resultStatus = exitCode === 0 ? "success" : "failed";
132
+ } else {
133
+ const status = stringField(toolResponse, "status") ?? stringField(raw, "status");
134
+ if (status) {
135
+ payload.resultStatus = status;
136
+ }
137
+ }
138
+
139
+ const responseSummary = summarizeToolResponse(toolResponse);
140
+ if (responseSummary) {
141
+ payload.responseSummary = responseSummary;
142
+ }
143
+ if (toolResponse) {
144
+ payload.responseHash = hashStable(JSON.stringify(toolResponse));
145
+ payload.responseStored = false;
146
+ }
147
+
148
+ return payload;
149
+ }
150
+
151
+ function stripRawPayload(value) {
152
+ if (Array.isArray(value)) {
153
+ return value.slice(0, 50).map((item) => stripRawPayload(item));
154
+ }
155
+ if (!value || typeof value !== "object") {
156
+ return value;
157
+ }
158
+ const output = {};
159
+ for (const [key, child] of Object.entries(value)) {
160
+ if (RAW_TEXT_KEYS.has(key)) {
161
+ output[`${key}Hash`] = hashStable(typeof child === "string" ? child : JSON.stringify(child ?? null));
162
+ output[`${key}Stored`] = false;
163
+ if (typeof child === "string" && !isPromptLikeKey(key)) {
164
+ const summary = safeSummary(child, 160);
165
+ if (summary) {
166
+ output[`${key}Summary`] = summary;
167
+ }
168
+ }
169
+ continue;
170
+ }
171
+ if (RAW_ARGS_KEYS.has(key)) {
172
+ output[`${key}Hash`] = hashStable(JSON.stringify(child ?? null));
173
+ output[`${key}Stored`] = false;
174
+ output[`${key}Shape`] = describeShape(child);
175
+ continue;
176
+ }
177
+ output[key] = stripRawPayload(child);
178
+ }
179
+ return output;
180
+ }
181
+
182
+ function objectField(value, key) {
183
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
184
+ return undefined;
185
+ }
186
+ const child = value[key];
187
+ return child && typeof child === "object" && !Array.isArray(child) ? child : undefined;
188
+ }
189
+
190
+ function stringField(value, key) {
191
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
192
+ return undefined;
193
+ }
194
+ const child = value[key];
195
+ return typeof child === "string" && child.length > 0 ? child : undefined;
196
+ }
197
+
198
+ function numberField(value, key) {
199
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
200
+ return undefined;
201
+ }
202
+ const child = value[key];
203
+ return typeof child === "number" && Number.isFinite(child) ? child : undefined;
204
+ }
205
+
206
+ function describeShape(value) {
207
+ if (Array.isArray(value)) {
208
+ return { kind: "array", length: value.length };
209
+ }
210
+ if (value && typeof value === "object") {
211
+ return {
212
+ kind: "object",
213
+ keys: Object.keys(value).slice(0, 20).sort(),
214
+ };
215
+ }
216
+ return { kind: typeof value };
217
+ }
218
+
219
+ function summarizeCommand(command) {
220
+ return safeSummary(
221
+ command
222
+ .replace(/--(token|api-key|apikey|password|secret)\s+[^\s]+/giu, "--$1 [REDACTED]")
223
+ .replace(/(["']?)(token|apiKey|api_key|password|secret)(["']?)\s*:\s*(["'])(.*?)\4/giu, "$1$2$3: \"[REDACTED]\""),
224
+ 220,
225
+ ) ?? "[command redacted]";
226
+ }
227
+
228
+ function summarizeToolResponse(response) {
229
+ if (!response || typeof response !== "object" || Array.isArray(response)) {
230
+ return undefined;
231
+ }
232
+ const text = stringField(response, "stdout") ?? stringField(response, "stderr") ??
233
+ stringField(response, "output") ?? stringField(response, "result");
234
+ return text ? safeSummary(text, 180) : undefined;
235
+ }
236
+
237
+ function safeSummary(value, maxLength) {
238
+ const normalized = scrubSecretTokens(value).replace(/\s+/gu, " ").trim();
239
+ if (!normalized) {
240
+ return undefined;
241
+ }
242
+ return normalized.length > maxLength ? `${normalized.slice(0, maxLength - 3)}...` : normalized;
243
+ }
244
+
245
+ function classifyCommand(toolName, command) {
246
+ if (toolName === "apply_patch" || command.includes("*** Begin Patch")) {
247
+ return "patch";
248
+ }
249
+ if (/^\s*(?:node|npm|bun|pnpm|yarn|forge|git)\b/u.test(command)) {
250
+ return "shell";
251
+ }
252
+ return "unknown";
253
+ }
254
+
255
+ function isPromptLikeKey(key) {
256
+ return key.toLowerCase().includes("prompt") || key.toLowerCase().includes("completion") || key.toLowerCase().includes("message");
257
+ }
258
+
259
+ function scrubSecretTokens(value) {
260
+ return value
261
+ .replace(/\bsk[-_][A-Za-z0-9_\-.]{8,}\b/gu, "[REDACTED]")
262
+ .replace(/\bnpm_[A-Za-z0-9]{16,}\b/gu, "[REDACTED]")
263
+ .replace(/\bgh[pousr]_[A-Za-z0-9_]{16,}\b/gu, "[REDACTED]")
264
+ .replace(/\b(?:xox[baprs]-)[A-Za-z0-9-]{16,}\b/gu, "[REDACTED]");
265
+ }
266
+
267
+ function hashStable(value) {
268
+ return createHash("sha256").update(value).digest("hex");
269
+ }
270
+
82
271
  main()
83
272
  .then(() => process.exit(0))
84
273
  .catch(() => process.exit(1));