@cassiomc1/forgeloop 1.9.0 → 1.10.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.
Files changed (57) hide show
  1. package/AGENT_COMPATIBILITY.md +15 -0
  2. package/DELEGATION_PROTOCOL.md +6 -0
  3. package/DOCS_INDEX.md +4 -2
  4. package/LOOP_ENGINEERING.md +13 -0
  5. package/LOOP_SYSTEM_DESIGN.md +33 -0
  6. package/ORCHESTRATOR_INTEGRATION.md +9 -0
  7. package/PROTOCOL_INTEGRATION.md +31 -0
  8. package/README.md +40 -0
  9. package/TERMINOLOGY.md +12 -0
  10. package/THREAT_MODEL.md +24 -0
  11. package/completions/_forgeloop +2 -1
  12. package/completions/forgeloop.bash +3 -1
  13. package/completions/forgeloop.fish +9 -1
  14. package/docs/ADVISORY_CONTEXT.md +174 -0
  15. package/docs/AGENT_PROTOCOL_SUMMARY.md +28 -2
  16. package/docs/ARTIFACT_REFERENCE.md +14 -0
  17. package/docs/CLI_REFERENCE.md +40 -0
  18. package/docs/CROSS_HARNESS_CONTINUITY.md +85 -0
  19. package/docs/DOCUMENTATION_GUIDE.md +7 -0
  20. package/docs/GETTING_STARTED.md +22 -0
  21. package/docs/KNOWLEDGE_SOURCES.md +10 -0
  22. package/docs/MCP.md +17 -1
  23. package/docs/RECIPES.md +80 -0
  24. package/docs/RELEASE_CHECKLIST.md +14 -0
  25. package/docs/TROUBLESHOOTING.md +54 -2
  26. package/docs/UNIVERSAL_INTEGRATION.md +60 -0
  27. package/package.json +2 -1
  28. package/schemas/handoff-envelope.schema.json +1 -0
  29. package/scripts/check-changelog-freshness.mjs +27 -3
  30. package/scripts/generate-agent-protocol-summary.mjs +18 -0
  31. package/src/cli.js +6 -0
  32. package/src/commands/handoff-accept.js +36 -0
  33. package/src/commands/handoff-list.js +28 -2
  34. package/src/commands/handoff-show.js +27 -2
  35. package/src/commands/reconcile-continuity.js +4 -0
  36. package/src/core/advisory-context/constants.js +74 -0
  37. package/src/core/advisory-context/provider.js +287 -0
  38. package/src/core/advisory-context/service.js +140 -0
  39. package/src/core/cli-command-definitions.js +17 -0
  40. package/src/core/command-executors.js +12 -0
  41. package/src/core/command-input.js +11 -1
  42. package/src/core/continuity-lint.js +89 -0
  43. package/src/core/continuity-reconciliation.js +16 -0
  44. package/src/core/continuity.js +10 -11
  45. package/src/core/error-codes.js +113 -0
  46. package/src/core/events.js +32 -0
  47. package/src/core/execution-profile-context.js +15 -1
  48. package/src/core/filesystem.js +18 -2
  49. package/src/core/handoff-acceptance.js +277 -0
  50. package/src/core/handoff.js +41 -8
  51. package/src/core/integration-invocation-policy.js +19 -2
  52. package/src/core/integration-resources.js +21 -1
  53. package/src/core/portable-context.js +103 -0
  54. package/src/core/protocol-info.js +18 -2
  55. package/src/core/runtime-context.js +31 -0
  56. package/src/integration.d.ts +116 -0
  57. package/src/integration.js +22 -0
@@ -0,0 +1,277 @@
1
+ import { canonicalFingerprint } from "./artifacts.js";
2
+ import { readContract } from "./contract.js";
3
+ import { readPersistedRoute } from "./route-artifact.js";
4
+ import { readWorkState } from "./work-state.js";
5
+ import { assertStateIdentity } from "./completion-relationships.js";
6
+ import { compareRepositoryFingerprint, readCanonicalHandoff } from "./handoff.js";
7
+ import { appendProtocolEvent, validateEventLedger } from "./events.js";
8
+ import { currentChangedPaths, currentRepositoryFingerprint } from "./repository.js";
9
+ import { withTaskTransaction } from "./transaction.js";
10
+ import { getPackageRoot } from "./templates.js";
11
+ import {
12
+ normalizePortableText,
13
+ assertPortableContextSafe,
14
+ } from "./portable-context.js";
15
+ import {
16
+ E_HANDOFF_ACCEPTANCE_UNBOUND,
17
+ E_HANDOFF_STALE,
18
+ E_HANDOFF_ALREADY_ACCEPTED,
19
+ E_HANDOFF_ACCEPTANCE_INCONSISTENT,
20
+ } from "./error-codes.js";
21
+
22
+ function acceptanceError(code, message) {
23
+ const error = new Error(message);
24
+ error.name = "HandoffAcceptanceError";
25
+ error.code = code;
26
+ return error;
27
+ }
28
+
29
+ function uniqueSorted(values) {
30
+ return [...new Set(values.filter(Boolean))].sort();
31
+ }
32
+
33
+ export function resolveHandoffAcceptance(input = {}, legacyHandoff) {
34
+ const projection = Array.isArray(input)
35
+ ? { events: input, handoff: legacyHandoff }
36
+ : input;
37
+ const {
38
+ events = [],
39
+ handoff,
40
+ ledgerValid = true,
41
+ ledgerErrors = [],
42
+ } = projection ?? {};
43
+
44
+ if (ledgerValid !== true) {
45
+ return {
46
+ status: "INCONSISTENT",
47
+ reasonCodes: uniqueSorted(ledgerErrors.map((error) => typeof error === "string" ? error : error?.code)),
48
+ };
49
+ }
50
+
51
+ if (!handoff?.state?.workStateFingerprint) {
52
+ return { status: "UNBOUND" };
53
+ }
54
+
55
+ const acceptedEvents = (events ?? []).filter(
56
+ (e) => e.event === "HANDOFF_ACCEPTED" && e.details?.handoffId === handoff.handoffId,
57
+ );
58
+
59
+ if (acceptedEvents.length === 0) {
60
+ return { status: "OPEN" };
61
+ }
62
+
63
+ if (acceptedEvents.length > 1) {
64
+ return { status: "INCONSISTENT" };
65
+ }
66
+
67
+ const accepted = acceptedEvents[0];
68
+ if (accepted.details?.handoffDigest !== handoff.artifactDigest) {
69
+ return { status: "INCONSISTENT" };
70
+ }
71
+
72
+ return {
73
+ status: "ACCEPTED",
74
+ consumerId: accepted.details?.consumerId,
75
+ ...(accepted.details?.harness ? { harness: accepted.details.harness } : {}),
76
+ acceptedAt: accepted.at,
77
+ };
78
+ }
79
+
80
+ export async function readHandoffAcceptanceLedger(target, packageRoot = getPackageRoot(), { taskId } = {}) {
81
+ try {
82
+ return await validateEventLedger(target, packageRoot, { taskId });
83
+ } catch (error) {
84
+ return {
85
+ valid: false,
86
+ events: [],
87
+ errors: [{
88
+ code: error.code ?? "E_EVENT_INVALID",
89
+ message: error.message,
90
+ }],
91
+ };
92
+ }
93
+ }
94
+
95
+ export async function acceptCanonicalHandoff(target, {
96
+ taskId,
97
+ handoffId,
98
+ consumerId,
99
+ harness,
100
+ packageRoot = getPackageRoot(),
101
+ } = {}) {
102
+ if (!target || typeof target !== "string") {
103
+ throw acceptanceError("E_TARGET_REQUIRED", "target path is required");
104
+ }
105
+ if (!taskId || typeof taskId !== "string") {
106
+ throw acceptanceError("E_TASK_REQUIRED", "taskId is required");
107
+ }
108
+ if (!handoffId || typeof handoffId !== "string") {
109
+ throw acceptanceError("E_HANDOFF_INVALID", "handoffId is required");
110
+ }
111
+
112
+ const normalizedConsumerId = normalizePortableText(consumerId, {
113
+ label: "consumerId",
114
+ maxLength: 128,
115
+ });
116
+ assertPortableContextSafe(normalizedConsumerId, { label: "consumerId" });
117
+
118
+ const normalizedHarness = harness !== undefined
119
+ ? normalizePortableText(harness, {
120
+ label: "harness",
121
+ maxLength: 64,
122
+ optional: true,
123
+ })
124
+ : null;
125
+ if (normalizedHarness) {
126
+ assertPortableContextSafe(normalizedHarness, { label: "harness" });
127
+ }
128
+
129
+ return withTaskTransaction(
130
+ {
131
+ target,
132
+ taskId,
133
+ packageRoot,
134
+ operation: "handoff-accept",
135
+ recordCommitEvent: true,
136
+ },
137
+ async () => {
138
+ const { value: handoff } = await readCanonicalHandoff(target, {
139
+ taskId,
140
+ handoffId,
141
+ packageRoot,
142
+ });
143
+
144
+ if (!handoff.state?.workStateFingerprint) {
145
+ throw acceptanceError(
146
+ E_HANDOFF_ACCEPTANCE_UNBOUND,
147
+ "Handoff snapshot is unbound (lacks state.workStateFingerprint); create a fresh handoff",
148
+ );
149
+ }
150
+
151
+ const currentState = await readWorkState(target, { packageRoot, taskId });
152
+ if (!currentState) {
153
+ throw acceptanceError(
154
+ E_HANDOFF_STALE,
155
+ "Canonical work state is unavailable for handoff acceptance",
156
+ );
157
+ }
158
+
159
+ const currentWorkStateFingerprint = canonicalFingerprint(currentState);
160
+ if (currentWorkStateFingerprint !== handoff.state.workStateFingerprint) {
161
+ throw acceptanceError(
162
+ E_HANDOFF_STALE,
163
+ "Current work state has drifted from handoff workStateFingerprint",
164
+ );
165
+ }
166
+
167
+ const contract = await readContract(target, packageRoot, { taskId });
168
+ const route = await readPersistedRoute(target, packageRoot, { taskId });
169
+ try {
170
+ assertStateIdentity({ contract, route, state: currentState });
171
+ } catch (error) {
172
+ throw acceptanceError(
173
+ E_HANDOFF_STALE,
174
+ `Current canonical task artifacts are not coherent with one another: ${error.message}`,
175
+ );
176
+ }
177
+ if (contract.fingerprint !== handoff.state.contractFingerprint) {
178
+ throw acceptanceError(
179
+ E_HANDOFF_STALE,
180
+ "Current contract fingerprint has drifted from handoff contractFingerprint",
181
+ );
182
+ }
183
+ if ((route.fingerprint ?? null) !== (handoff.state.routeFingerprint ?? null)) {
184
+ throw acceptanceError(
185
+ E_HANDOFF_STALE,
186
+ "Current route fingerprint has drifted from handoff routeFingerprint",
187
+ );
188
+ }
189
+
190
+ const repositoryFingerprint = await currentRepositoryFingerprint(target);
191
+ if (compareRepositoryFingerprint(handoff.state.repositoryFingerprint, repositoryFingerprint) === "MISMATCH") {
192
+ throw acceptanceError(
193
+ E_HANDOFF_STALE,
194
+ "Current repository branch or HEAD has drifted from handoff snapshot",
195
+ );
196
+ }
197
+
198
+ const changedPaths = await currentChangedPaths(target);
199
+ const currentList = [...new Set((changedPaths ?? []).map((p) => p.replaceAll("\\", "/")))].sort();
200
+ const handoffList = [...new Set((handoff.state.changedPaths ?? []).map((p) => p.replaceAll("\\", "/")))].sort();
201
+ if (JSON.stringify(currentList) !== JSON.stringify(handoffList)) {
202
+ throw acceptanceError(
203
+ E_HANDOFF_STALE,
204
+ "Current changed paths have drifted from handoff snapshot",
205
+ );
206
+ }
207
+
208
+ const ledgerResult = await validateEventLedger(target, packageRoot, { taskId });
209
+ if (!ledgerResult.valid) {
210
+ throw acceptanceError(
211
+ E_HANDOFF_ACCEPTANCE_INCONSISTENT,
212
+ "Task event ledger is invalid",
213
+ );
214
+ }
215
+
216
+ const events = ledgerResult.events;
217
+ const createdEvent = events.find(
218
+ (e) => e.event === "HANDOFF_CREATED" && e.details?.handoffId === handoffId,
219
+ );
220
+ if (!createdEvent || createdEvent.details?.digest !== handoff.artifactDigest) {
221
+ throw acceptanceError(
222
+ E_HANDOFF_ACCEPTANCE_INCONSISTENT,
223
+ "No matching HANDOFF_CREATED event with matching digest found in ledger",
224
+ );
225
+ }
226
+
227
+ const existingAccepted = events.filter(
228
+ (e) => e.event === "HANDOFF_ACCEPTED" && e.details?.handoffId === handoffId,
229
+ );
230
+
231
+ if (existingAccepted.length > 0) {
232
+ const prev = existingAccepted[0];
233
+ if (prev.details?.consumerId === normalizedConsumerId) {
234
+ return {
235
+ accepted: true,
236
+ idempotent: true,
237
+ handoffId,
238
+ consumerId: normalizedConsumerId,
239
+ harness: prev.details?.harness ?? null,
240
+ acceptedAt: prev.at,
241
+ };
242
+ }
243
+ throw acceptanceError(
244
+ E_HANDOFF_ALREADY_ACCEPTED,
245
+ `Handoff ${handoffId} has already been accepted by consumer "${prev.details?.consumerId}"`,
246
+ );
247
+ }
248
+
249
+ const eventDetails = {
250
+ handoffId,
251
+ handoffDigest: handoff.artifactDigest,
252
+ consumerId: normalizedConsumerId,
253
+ ...(normalizedHarness ? { harness: normalizedHarness } : {}),
254
+ };
255
+
256
+ const appended = await appendProtocolEvent(
257
+ target,
258
+ {
259
+ taskId,
260
+ event: "HANDOFF_ACCEPTED",
261
+ details: eventDetails,
262
+ },
263
+ packageRoot,
264
+ { taskId },
265
+ );
266
+
267
+ return {
268
+ accepted: true,
269
+ idempotent: false,
270
+ handoffId,
271
+ consumerId: normalizedConsumerId,
272
+ harness: normalizedHarness ?? null,
273
+ acceptedAt: appended.at,
274
+ };
275
+ },
276
+ );
277
+ }
@@ -2,12 +2,13 @@ import { randomUUID } from "node:crypto";
2
2
  import { readdir } from "node:fs/promises";
3
3
 
4
4
  import { canonicalFingerprint, readJsonArtifact, writeJsonArtifact } from "./artifacts.js";
5
- import { assertSecretFree } from "./receipt.js";
5
+ import { assertPortableContextSafe, normalizePortableText } from "./portable-context.js";
6
6
  import { appendProtocolEvent } from "./events.js";
7
- import { currentChangedPaths } from "./repository.js";
7
+ import { currentChangedPaths, currentRepositoryFingerprint } from "./repository.js";
8
8
  import { readContract } from "./contract.js";
9
9
  import { readPersistedRoute } from "./route-artifact.js";
10
10
  import { readWorkState } from "./work-state.js";
11
+ import { assertStateIdentity } from "./completion-relationships.js";
11
12
  import { resolveTaskClaimState } from "./task-claim-state.js";
12
13
  import { readContinuity } from "./continuity.js";
13
14
  import { getPackageRoot } from "./templates.js";
@@ -26,12 +27,13 @@ function handoffError(code, message, artifacts = []) {
26
27
  return error;
27
28
  }
28
29
 
29
- function optionalText(value, label) {
30
+ function optionalText(value, label, maxLength = 2000) {
30
31
  if (value === undefined || value === null) return null;
31
- if (typeof value !== "string" || value.trim() === "") {
32
+ try {
33
+ return normalizePortableText(value, { label, maxLength, optional: true });
34
+ } catch (error) {
32
35
  throw handoffError("E_HANDOFF_INVALID", `${label} must be a non-empty string when provided`);
33
36
  }
34
- return value;
35
37
  }
36
38
 
37
39
  function pathList(value) {
@@ -43,8 +45,22 @@ function handoffWithoutDigest(value) {
43
45
  return body;
44
46
  }
45
47
 
48
+ export function compareRepositoryFingerprint(expected, current) {
49
+ const expectedUnavailable = !expected || (expected.branch === null && expected.head === null);
50
+ const currentUnavailable = !current || (current.branch === null && current.head === null);
51
+ if (expectedUnavailable && currentUnavailable) return "NOT_VERIFIED";
52
+ if (expectedUnavailable !== currentUnavailable) return "MISMATCH";
53
+ return expected.branch === current.branch && expected.head === current.head
54
+ ? "MATCH"
55
+ : "MISMATCH";
56
+ }
57
+
46
58
  export function validateHandoffDigest(handoff) {
47
- assertSecretFree(handoff);
59
+ try {
60
+ assertPortableContextSafe(handoff);
61
+ } catch (error) {
62
+ throw handoffError("E_HANDOFF_INVALID", error.message);
63
+ }
48
64
  if (typeof handoff?.artifactDigest !== "string"
49
65
  || handoff.artifactDigest !== canonicalFingerprint(handoffWithoutDigest(handoff))) {
50
66
  throw handoffError("E_HANDOFF_TAMPERED", "Handoff artifact digest does not match its canonical content");
@@ -73,17 +89,33 @@ export async function buildCanonicalHandoff(target, {
73
89
  createdAt = new Date().toISOString(),
74
90
  } = {}) {
75
91
  if (!taskId) throw handoffError("E_HANDOFF_STATE_UNAVAILABLE", "taskId is required to build a handoff");
76
- const [state, contract, route, claims, changedPaths, continuity] = await Promise.all([
92
+ const [state, contract, route, claims, changedPaths, continuity, repositoryFingerprint] = await Promise.all([
77
93
  readWorkState(target, { packageRoot, taskId }),
78
94
  readContract(target, packageRoot, { taskId }),
79
95
  readPersistedRoute(target, packageRoot, { taskId }),
80
96
  resolveTaskClaimState(target, { packageRoot, taskId }),
81
97
  currentChangedPaths(target),
82
98
  currentContinuity(target, packageRoot, taskId),
99
+ currentRepositoryFingerprint(target),
83
100
  ]);
84
101
  if (!state || !contract || !route || changedPaths === null || !claims.valid) {
85
102
  throw handoffError("E_HANDOFF_STATE_UNAVAILABLE", "Canonical task state, route, claims, and changed paths are required for a handoff");
86
103
  }
104
+ try {
105
+ assertStateIdentity({ contract, route, state });
106
+ } catch (error) {
107
+ throw handoffError(
108
+ "E_HANDOFF_STATE_UNAVAILABLE",
109
+ `Canonical task artifacts are not coherent for handoff creation: ${error.message}`,
110
+ error.artifacts ?? [],
111
+ );
112
+ }
113
+ if (compareRepositoryFingerprint(state.repositoryFingerprint, repositoryFingerprint) === "MISMATCH") {
114
+ throw handoffError(
115
+ "E_HANDOFF_STATE_UNAVAILABLE",
116
+ "Canonical repository fingerprint has drifted from current repository state",
117
+ );
118
+ }
87
119
  const checkItems = [...(state.checks ?? [])];
88
120
  const executionRefs = pathList(checkItems.map((check) => check.executionRef).filter(Boolean));
89
121
  const checkIds = pathList(checkItems.map((check) => check.id).filter(Boolean));
@@ -101,9 +133,10 @@ export async function buildCanonicalHandoff(target, {
101
133
  phase: WORK_PHASES.includes(state.phase) ? state.phase : "UNKNOWN",
102
134
  revision: state.revision ?? 0,
103
135
  verificationCycle: state.verificationCycle ?? 1,
136
+ workStateFingerprint: canonicalFingerprint(state),
104
137
  contractFingerprint: contract.fingerprint,
105
138
  routeFingerprint: route.fingerprint ?? null,
106
- repositoryFingerprint: state.repositoryFingerprint ?? { branch: null, head: null },
139
+ repositoryFingerprint,
107
140
  writeClaims: pathList(claims.effectiveWriteClaims ?? []),
108
141
  changedPaths: pathList(changedPaths),
109
142
  },
@@ -42,7 +42,7 @@ const LOOP_MUTATION_COMMANDS = Object.freeze(new Set([
42
42
  "record-terminal-result", "complete",
43
43
  "record-intervention", "record-hypothesis-disposition",
44
44
  "usage-record",
45
- "workspace-bind", "handoff-create", "responsibility-set", "verify-scope", "attestation-create",
45
+ "workspace-bind", "handoff-create", "handoff-accept", "responsibility-set", "verify-scope", "attestation-create",
46
46
  ]));
47
47
 
48
48
  const STATIC_RISK_CLASSES = Object.freeze({
@@ -189,10 +189,27 @@ export function getForgeLoopCapabilities({ packageVersion = null } = {}) {
189
189
  explicitRebinding: false,
190
190
  },
191
191
  canonicalHandoffs: {
192
- version: 1,
192
+ version: 2,
193
193
  supported: true,
194
194
  immutable: true,
195
195
  lifecycleAuthority: false,
196
+ evidenceAuthority: false,
197
+ exactlyOnceAcceptance: true,
198
+ acceptanceLedgerBacked: true,
199
+ acceptanceCommand: "handoff-accept",
200
+ acceptanceStatuses: ["OPEN", "ACCEPTED", "UNBOUND", "INCONSISTENT"],
201
+ },
202
+ advisoryContextProviders: {
203
+ version: 1,
204
+ supported: true,
205
+ providerNeutral: true,
206
+ integrationApiOnly: true,
207
+ lazy: true,
208
+ optIn: true,
209
+ persistedByForgeLoop: false,
210
+ lifecycleAuthority: false,
211
+ evidenceAuthority: false,
212
+ executable: false,
196
213
  },
197
214
  responsibilityConstraints: {
198
215
  version: 1,
@@ -14,6 +14,7 @@ import { readJsonArtifact } from "./artifacts.js";
14
14
  import { taskDirectory } from "./task-paths.js";
15
15
  import { resolveWorkspaceBindingStatus } from "./workspace-binding.js";
16
16
  import { listCanonicalHandoffs } from "./handoff.js";
17
+ import { readHandoffAcceptanceLedger, resolveHandoffAcceptance } from "./handoff-acceptance.js";
17
18
  import { resolveResponsibilityStatus } from "./responsibility.js";
18
19
  import { readVerificationScope } from "./verification-scope.js";
19
20
  import { resolveAttestationStatus } from "./attestation.js";
@@ -196,7 +197,26 @@ export async function readForgeLoopIntegrationResource(uri, {
196
197
  }
197
198
  if (uri === "task/handoffs") {
198
199
  const handoffs = await listCanonicalHandoffs(projectPath, { packageRoot, taskId });
199
- return { uri, taskId, data: { taskId, count: handoffs.length, handoffs } };
200
+ const ledger = await readHandoffAcceptanceLedger(projectPath, packageRoot, { taskId });
201
+ const projectedHandoffs = handoffs.map((handoff) => {
202
+ const resolved = resolveHandoffAcceptance({
203
+ events: ledger.events,
204
+ handoff,
205
+ ledgerValid: ledger.valid,
206
+ ledgerErrors: ledger.errors,
207
+ });
208
+ return {
209
+ ...handoff,
210
+ acceptance: {
211
+ status: resolved.status,
212
+ consumerId: resolved.consumerId ?? null,
213
+ harness: resolved.harness ?? null,
214
+ acceptedAt: resolved.acceptedAt ?? null,
215
+ ...(resolved.reasonCodes ? { reasonCodes: [...resolved.reasonCodes] } : {}),
216
+ },
217
+ };
218
+ });
219
+ return { uri, taskId, data: { taskId, count: projectedHandoffs.length, handoffs: projectedHandoffs } };
200
220
  }
201
221
  if (uri === "task/responsibility") {
202
222
  return { uri, taskId, data: await resolveResponsibilityStatus(projectPath, { packageRoot, taskId }) };
@@ -0,0 +1,103 @@
1
+ import { assertJsonLimits } from "./json-safety.js";
2
+ import { assertSecretFree } from "./receipt.js";
3
+ import { E_PORTABLE_CONTEXT_INVALID } from "./error-codes.js";
4
+
5
+ export class PortableContextError extends Error {
6
+ constructor(message, { code = E_PORTABLE_CONTEXT_INVALID, cause } = {}) {
7
+ super(message, cause !== undefined ? { cause } : undefined);
8
+ this.name = "PortableContextError";
9
+ this.code = code;
10
+ }
11
+ }
12
+
13
+ const PORTABLE_SECRET_PATTERNS = [
14
+ /-----BEGIN [A-Z ]*PRIVATE KEY-----/i,
15
+ /(?:^|\s)(?:sk|ghp|glpat|xox[baprs])-[-_a-z0-9]{8,}/i,
16
+ /(?:AKIA|ASIA)[A-Z0-9]{12,}/,
17
+ /(?:bearer\s+[-_a-z0-9\.]{4,})/i,
18
+ /(?:authorization:\s*bearer)/i,
19
+ ];
20
+
21
+ function containsSecretPattern(str) {
22
+ return PORTABLE_SECRET_PATTERNS.some((pattern) => pattern.test(str));
23
+ }
24
+
25
+ function checkObjectForSecrets(value, location = "$") {
26
+ if (typeof value === "string") {
27
+ if (containsSecretPattern(value)) {
28
+ throw new Error(`${location}: secret-like value is not allowed`);
29
+ }
30
+ return;
31
+ }
32
+ if (!value || typeof value !== "object") return;
33
+ if (Array.isArray(value)) {
34
+ value.forEach((item, index) => checkObjectForSecrets(item, `${location}[${index}]`));
35
+ } else {
36
+ for (const [key, child] of Object.entries(value)) {
37
+ checkObjectForSecrets(child, `${location}.${key}`);
38
+ }
39
+ }
40
+ }
41
+
42
+ export function normalizePortableText(
43
+ value,
44
+ {
45
+ label = "portable text",
46
+ maxLength,
47
+ optional = false,
48
+ } = {},
49
+ ) {
50
+ if (value === undefined || value === null) {
51
+ if (optional) return null;
52
+ throw new PortableContextError(`${label} is required`);
53
+ }
54
+
55
+ if (typeof value !== "string" || value.trim() === "") {
56
+ throw new PortableContextError(`${label} must be a non-empty string`);
57
+ }
58
+
59
+ if (typeof maxLength === "number" && value.length > maxLength) {
60
+ throw new PortableContextError(`${label} exceeds the ${maxLength}-character limit`);
61
+ }
62
+
63
+ if (/\p{Cc}/u.test(value)) {
64
+ throw new PortableContextError(`${label} contains control characters`);
65
+ }
66
+
67
+ return value;
68
+ }
69
+
70
+ export function assertPortableContextSafe(
71
+ value,
72
+ {
73
+ label = "portable context",
74
+ } = {},
75
+ ) {
76
+ try {
77
+ assertJsonLimits(value, label);
78
+ assertSecretFree(value);
79
+ checkObjectForSecrets(value, label);
80
+ return value;
81
+ } catch (error) {
82
+ if (error.code === E_PORTABLE_CONTEXT_INVALID) {
83
+ throw error;
84
+ }
85
+ throw new PortableContextError(
86
+ `${label} failed safety verification: ${error.message}`,
87
+ { code: E_PORTABLE_CONTEXT_INVALID, cause: error },
88
+ );
89
+ }
90
+ }
91
+
92
+ export function deepFreeze(object) {
93
+ if (object === null || typeof object !== "object" || Object.isFrozen(object)) {
94
+ return object;
95
+ }
96
+ Object.freeze(object);
97
+ for (const value of Object.values(object)) {
98
+ if (value !== null && typeof value === "object") {
99
+ deepFreeze(value);
100
+ }
101
+ }
102
+ return object;
103
+ }
@@ -160,11 +160,27 @@ export function protocolInfo({ packageVersion = null } = {}) {
160
160
  rebinding: "EXPLICIT_ONLY",
161
161
  },
162
162
  canonicalHandoffs: {
163
- version: 1,
163
+ version: 2,
164
164
  supported: true,
165
165
  immutable: true,
166
- actorControlledIntent: true,
167
166
  lifecycleAuthority: false,
167
+ evidenceAuthority: false,
168
+ exactlyOnceAcceptance: true,
169
+ acceptanceLedgerBacked: true,
170
+ acceptanceCommand: "handoff-accept",
171
+ acceptanceStatuses: ["OPEN", "ACCEPTED", "UNBOUND", "INCONSISTENT"],
172
+ },
173
+ advisoryContextProviders: {
174
+ version: 1,
175
+ supported: true,
176
+ providerNeutral: true,
177
+ integrationApiOnly: true,
178
+ lazy: true,
179
+ optIn: true,
180
+ persistedByForgeLoop: false,
181
+ lifecycleAuthority: false,
182
+ evidenceAuthority: false,
183
+ executable: false,
168
184
  },
169
185
  responsibilityConstraints: {
170
186
  version: 1,
@@ -4,6 +4,8 @@ import {
4
4
  normalizeVerificationExecutionPolicy,
5
5
  } from "./verification-execution.js";
6
6
  import { STRUCTURAL_QUALITY_PROVIDER_ID_PATTERN } from "./structural-quality/constants.js";
7
+ import { E_ADVISORY_CONTEXT_PROVIDER_INVALID } from "./error-codes.js";
8
+ import { assertAdvisoryContextProviderIdentity } from "./advisory-context/provider.js";
7
9
 
8
10
  export const AUTHORITY_TRUST_MODES = Object.freeze(["NONE", "HOST_ATTESTED"]);
9
11
 
@@ -132,5 +134,34 @@ export function createForgeLoopContext(options = {}) {
132
134
  }
133
135
  context.structuralQualityProviders = Object.freeze(providers);
134
136
  }
137
+ if (options?.advisoryContextProviders !== undefined) {
138
+ const configured = options.advisoryContextProviders instanceof Map
139
+ ? Object.fromEntries(options.advisoryContextProviders.entries())
140
+ : options.advisoryContextProviders;
141
+ if (!configured || typeof configured !== "object" || Array.isArray(configured)) {
142
+ const error = new Error("advisoryContextProviders must be an object or Map");
143
+ error.code = E_ADVISORY_CONTEXT_PROVIDER_INVALID;
144
+ throw error;
145
+ }
146
+ const providers = {};
147
+ for (const [id, provider] of Object.entries(configured)) {
148
+ if (!/^[a-z0-9][a-z0-9_-]*$/.test(id)) {
149
+ const error = new Error(`Invalid advisory-context provider ID: ${id}`);
150
+ error.code = E_ADVISORY_CONTEXT_PROVIDER_INVALID;
151
+ throw error;
152
+ }
153
+ if (typeof provider !== "function"
154
+ && (!provider || typeof provider !== "object" || Array.isArray(provider))) {
155
+ const error = new Error(`Advisory-context provider ${id} must be an object or factory`);
156
+ error.code = E_ADVISORY_CONTEXT_PROVIDER_INVALID;
157
+ throw error;
158
+ }
159
+ if (typeof provider !== "function") {
160
+ assertAdvisoryContextProviderIdentity(provider, id);
161
+ }
162
+ providers[id] = provider;
163
+ }
164
+ context.advisoryContextProviders = Object.freeze(providers);
165
+ }
135
166
  return Object.freeze(context);
136
167
  }