forgedock 1.2.0 → 1.4.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 (78) hide show
  1. package/README.md +8 -2
  2. package/bin/batch-runner.mjs +31 -2
  3. package/bin/engine/admission.mjs +294 -0
  4. package/bin/engine/admission.test.mjs +265 -0
  5. package/bin/engine/invariants.mjs +17 -4
  6. package/bin/engine/phases.mjs +358 -42
  7. package/bin/engine/resolve.mjs +256 -0
  8. package/bin/engine/resolve.test.mjs +149 -0
  9. package/bin/engine/state.mjs +31 -7
  10. package/bin/engine-cli.mjs +138 -2
  11. package/bin/engine.mjs +414 -21
  12. package/bin/forgedock.mjs +552 -44
  13. package/bin/hooks/interactive-engine.mjs +50 -24
  14. package/bin/hooks/pre-tool-use.mjs +189 -19
  15. package/bin/journey.mjs +109 -11
  16. package/bin/labels.json +5 -0
  17. package/bin/runner.mjs +321 -10
  18. package/bin/watch-utils.mjs +255 -0
  19. package/commands/cleanup.md +7 -3
  20. package/commands/forge-stats.md +5 -4
  21. package/commands/issue.md +5 -1
  22. package/commands/orchestrate/config.md +41 -1
  23. package/commands/orchestrate/phase-1-resolve.md +234 -8
  24. package/commands/orchestrate/phase-3-dependency.md +27 -18
  25. package/commands/orchestrate/phase-4-execution.md +223 -22
  26. package/commands/qa-sweep.md +17 -0
  27. package/commands/quality-gate.md +6 -6
  28. package/commands/replay.md +16 -2
  29. package/commands/review-pr-agents/spec-cli.md +179 -0
  30. package/commands/review-pr.md +65 -0
  31. package/commands/upgrade-deps.md +523 -0
  32. package/commands/work-on/build/validate.md +61 -1
  33. package/commands/work-on/build.md +9 -1
  34. package/commands/work-on/decompose.md +10 -1
  35. package/commands/work-on/investigate.md +22 -4
  36. package/commands/work-on/remediate.md +2 -0
  37. package/commands/work-on/review.md +40 -1
  38. package/commands/work-on.md +19 -4
  39. package/docs/AIDER.md +264 -0
  40. package/docs/AUTOPILOT-CRON.md +130 -0
  41. package/docs/CONFIG.md +156 -0
  42. package/docs/CURSOR.md +135 -0
  43. package/docs/OPENCODE.md +312 -0
  44. package/docs/demo.gif +0 -0
  45. package/docs/eval/README.md +4 -4
  46. package/docs/launch/show-hn.md +3 -3
  47. package/docs/site/architecture.md +180 -0
  48. package/docs/site/command-reference.md +17 -0
  49. package/docs/site/configuration.md +243 -0
  50. package/docs/site/for-companies.md +4 -4
  51. package/docs/site/forge-annotation-protocol.md +1 -1
  52. package/docs/site/github-is-the-memory.md +2 -2
  53. package/docs/site/how-it-works.md +1 -1
  54. package/docs/site/public/llms-full.txt +380 -0
  55. package/docs/site/public/llms.txt +35 -0
  56. package/docs/spec/eval-run-result.md +8 -4
  57. package/docs/spec/label-state-machine.md +9 -0
  58. package/docs/spec-graph-schema.md +3 -3
  59. package/package.json +3 -3
  60. package/packages/protocol/README.md +75 -0
  61. package/packages/protocol/src/card.js +114 -0
  62. package/packages/protocol/src/cli.js +4 -77
  63. package/packages/protocol/src/emit.js +5 -11
  64. package/packages/protocol/src/html-comment-escape.js +68 -0
  65. package/packages/protocol/src/index.js +3 -1
  66. package/packages/protocol/src/parse.js +19 -11
  67. package/packages/protocol/src/phases.js +105 -0
  68. package/packages/protocol/src/types.js +18 -1
  69. package/packages/protocol/src/validate.js +11 -0
  70. package/scripts/check-admission-parity.mjs +167 -0
  71. package/scripts/check-command-side-effects.sh +376 -0
  72. package/scripts/check-phase-registry-drift.mjs +96 -0
  73. package/scripts/check-spec-markers.sh +274 -0
  74. package/scripts/classify-lane.sh +29 -7
  75. package/scripts/forge-run.sh +353 -0
  76. package/templates/cross-repo-trigger.yml +282 -0
  77. package/templates/cursor/forge.mdc +148 -0
  78. package/templates/github-actions/forgedock-autopilot.yml +113 -0
@@ -0,0 +1,114 @@
1
+ /**
2
+ * FORGE Annotation Protocol v1.0 — CARD codec.
3
+ *
4
+ * Implements the Base64url machine-surface encoding used by the CARD reserved
5
+ * type (§4.2, forge#1727): `<!-- FORGE:CARD: v1 sha:<sha8hex> b64:<base64url> -->`.
6
+ *
7
+ * Design decision (2026-07-08): the Base64url alphabet [A-Za-z0-9_-] cannot
8
+ * contain '>', '<', or '!' — so `-->`, `--!>`, and `<!--` are structurally
9
+ * unrepresentable in the encoded payload, by construction. This sidesteps the
10
+ * escaping-related round-trip bugs that affected the plain field-value/inline
11
+ * form of other reserved types (forge#1637, forge#1638, forge#1662) — CARD
12
+ * payloads never need escaping in the first place.
13
+ *
14
+ * This module is the single production code path for CARD encode/decode: it
15
+ * is used by `cli.js` (the `emit --b64` / `parse --field` subcommands),
16
+ * `validate.js` (CARD integrity checking), and the conformance test suite.
17
+ *
18
+ * @license MIT
19
+ */
20
+
21
+ import { createHash } from 'node:crypto';
22
+
23
+ /**
24
+ * Produce canonical JSON: sorted keys (recursively), single line, UTF-8.
25
+ * This is the payload that gets Base64url-encoded in the CARD format.
26
+ *
27
+ * @param {*} obj
28
+ * @returns {string}
29
+ */
30
+ export function canonicalJson(obj) {
31
+ return JSON.stringify(sortKeysDeep(obj));
32
+ }
33
+
34
+ function sortKeysDeep(val) {
35
+ if (Array.isArray(val)) return val.map(sortKeysDeep);
36
+ if (val !== null && typeof val === 'object') {
37
+ return Object.fromEntries(
38
+ Object.keys(val)
39
+ .sort()
40
+ .map(k => [k, sortKeysDeep(val[k])]),
41
+ );
42
+ }
43
+ return val;
44
+ }
45
+
46
+ /**
47
+ * Encode a UTF-8 string as Base64url (URL-safe alphabet, no padding).
48
+ *
49
+ * @param {string} str — UTF-8 string to encode
50
+ * @returns {string} Base64url-encoded string without padding
51
+ */
52
+ export function toBase64url(str) {
53
+ return Buffer.from(str, 'utf8')
54
+ .toString('base64')
55
+ .replace(/\+/g, '-')
56
+ .replace(/\//g, '_')
57
+ .replace(/=/g, '');
58
+ }
59
+
60
+ /**
61
+ * Encode a JS value into the CARD wire components: the sha8 integrity prefix
62
+ * (first 8 hex chars of SHA-256 over the canonical JSON payload) and the
63
+ * Base64url-encoded canonical payload.
64
+ *
65
+ * Composes the full inline value with `\`v1 sha:${sha8} b64:${b64url}\`` to
66
+ * build the complete `<!-- FORGE:CARD: v1 sha:<sha8> b64:<base64url> -->` tag.
67
+ *
68
+ * @param {*} payload — the JS value to encode (typically a plain object)
69
+ * @returns {{ sha8: string, b64url: string, inlineValue: string }}
70
+ */
71
+ export function encodeCard(payload) {
72
+ const canonical = canonicalJson(payload);
73
+ const b64url = toBase64url(canonical);
74
+ const sha8 = createHash('sha256').update(canonical, 'utf8').digest('hex').slice(0, 8);
75
+ return { sha8, b64url, inlineValue: `v1 sha:${sha8} b64:${b64url}` };
76
+ }
77
+
78
+ /**
79
+ * Decode a CARD inline value of the form "v1 sha:<sha8hex> b64:<base64url>"
80
+ * and return the parsed JSON value, or null if the format is unrecognized,
81
+ * the Base64url payload fails to decode, the sha8 integrity prefix does not
82
+ * match, or the decoded payload is not valid JSON.
83
+ *
84
+ * @param {string} inlineValue — the parsed inlineValue from a CARD annotation
85
+ * @returns {*|null}
86
+ */
87
+ export function decodeCardInlineValue(inlineValue) {
88
+ if (typeof inlineValue !== 'string') return null;
89
+
90
+ const m = inlineValue.match(/^v1\s+sha:([0-9a-f]{8})\s+b64:([A-Za-z0-9_-]+)$/);
91
+ if (!m) return null;
92
+ const [, sha8, b64url] = m;
93
+
94
+ // Restore standard Base64 padding and swap the URL-safe alphabet back.
95
+ const b64std = b64url.replace(/-/g, '+').replace(/_/g, '/');
96
+ const padded = b64std + '='.repeat((4 - (b64std.length % 4)) % 4);
97
+
98
+ let canonical;
99
+ try {
100
+ canonical = Buffer.from(padded, 'base64').toString('utf8');
101
+ } catch {
102
+ return null;
103
+ }
104
+
105
+ // Integrity check: first 8 hex chars of SHA-256 must match the prefix.
106
+ const expectedSha8 = createHash('sha256').update(canonical, 'utf8').digest('hex').slice(0, 8);
107
+ if (expectedSha8 !== sha8) return null;
108
+
109
+ try {
110
+ return JSON.parse(canonical);
111
+ } catch {
112
+ return null;
113
+ }
114
+ }
@@ -42,10 +42,10 @@
42
42
 
43
43
  import { readFileSync, readdirSync } from 'node:fs';
44
44
  import { resolve, extname } from 'node:path';
45
- import { createHash } from 'node:crypto';
46
45
  import { parse } from './parse.js';
47
46
  import { validate } from './validate.js';
48
47
  import { emit } from './emit.js';
48
+ import { encodeCard, decodeCardInlineValue } from './card.js';
49
49
 
50
50
  // ---------------------------------------------------------------------------
51
51
  // Argv routing
@@ -114,16 +114,15 @@ function cmdEmit(argv) {
114
114
  // Payload: canonical JSON (sorted keys, single line, UTF-8).
115
115
  // The Base64url alphabet [A-Za-z0-9_-] cannot contain '>', '<', or '!' —
116
116
  // so -->, --!>, and <!-- are structurally unrepresentable by construction.
117
+ // Codec lives in ./card.js (shared with validate.js and the test suite).
117
118
  const payload = Object.keys(fields).length > 0 ? { ...fields } : {};
118
119
  if (type) payload.type = type.toUpperCase();
119
- const canonical = canonicalJson(payload);
120
- const b64url = toBase64url(canonical);
121
- const sha8 = createHash('sha256').update(canonical, 'utf8').digest('hex').slice(0, 8);
120
+ const { inlineValue } = encodeCard(payload);
122
121
  // The colon-separator form "<!-- FORGE:CARD: v1 sha:X b64:Y -->" matches
123
122
  // OPENING_TAG_RE so parse() can extract the inline value "v1 sha:X b64:Y".
124
123
  // This keeps the canonical FORGE inline-value syntax (§3.4) while adding
125
124
  // the sha8 integrity prefix and Base64url payload from the design decision.
126
- process.stdout.write(`<!-- FORGE:CARD: v1 sha:${sha8} b64:${b64url} -->\n`);
125
+ process.stdout.write(`<!-- FORGE:CARD: ${inlineValue} -->\n`);
127
126
  return;
128
127
  }
129
128
 
@@ -351,75 +350,3 @@ function die(msg) {
351
350
  process.stderr.write(`forge-protocol-cli: ${msg}\n`);
352
351
  process.exit(1);
353
352
  }
354
-
355
- /**
356
- * Produce canonical JSON: sorted keys, single line, UTF-8.
357
- * This is the payload that gets Base64url-encoded in the CARD format.
358
- */
359
- function canonicalJson(obj) {
360
- return JSON.stringify(sortKeysDeep(obj));
361
- }
362
-
363
- function sortKeysDeep(val) {
364
- if (Array.isArray(val)) return val.map(sortKeysDeep);
365
- if (val !== null && typeof val === 'object') {
366
- return Object.fromEntries(
367
- Object.keys(val)
368
- .sort()
369
- .map(k => [k, sortKeysDeep(val[k])]),
370
- );
371
- }
372
- return val;
373
- }
374
-
375
- /**
376
- * Encode a UTF-8 string as Base64url (URL-safe alphabet, no padding).
377
- * The Base64url alphabet [A-Za-z0-9_-] cannot contain '>', '<', or '!' —
378
- * so -->, --!>, and <!-- are structurally unrepresentable by construction
379
- * (design decision 2026-07-08: encoding beats escaping).
380
- *
381
- * @param {string} str — UTF-8 string to encode
382
- * @returns {string} Base64url-encoded string without padding
383
- */
384
- function toBase64url(str) {
385
- return Buffer.from(str, 'utf8')
386
- .toString('base64')
387
- .replace(/\+/g, '-')
388
- .replace(/\//g, '_')
389
- .replace(/=/g, '');
390
- }
391
-
392
- /**
393
- * Decode a CARD inline value of the form "v1 sha:<sha8hex> b64:<base64url>"
394
- * and return the parsed JSON object, or null if the format is unrecognized.
395
- * Verifies the sha8 integrity prefix; returns null on corruption or tampering.
396
- *
397
- * @param {string} inlineValue — the parsed inlineValue from a CARD annotation
398
- * @returns {object|null}
399
- */
400
- function decodeCardInlineValue(inlineValue) {
401
- const m = inlineValue.match(/^v1\s+sha:([0-9a-f]{8})\s+b64:([A-Za-z0-9_-]+)$/);
402
- if (!m) return null;
403
- const [, sha8, b64url] = m;
404
-
405
- // Restore standard Base64 padding and swap the URL-safe alphabet back.
406
- const b64std = b64url.replace(/-/g, '+').replace(/_/g, '/');
407
- const padded = b64std + '='.repeat((4 - (b64std.length % 4)) % 4);
408
-
409
- let canonical;
410
- try {
411
- canonical = Buffer.from(padded, 'base64').toString('utf8');
412
- } catch {
413
- return null;
414
- }
415
-
416
- // Integrity check: first 8 hex chars of SHA-256 must match the prefix.
417
- const expectedSha8 = createHash('sha256').update(canonical, 'utf8').digest('hex').slice(0, 8);
418
- if (expectedSha8 !== sha8) return null;
419
-
420
- try {
421
- return JSON.parse(canonical);
422
- } catch {
423
- return null;
424
- }
425
- }
@@ -12,6 +12,7 @@
12
12
  */
13
13
 
14
14
  import { RESERVED_TYPES, RESERVED_TYPE_NAMES } from './types.js';
15
+ import { escapeHtmlCommentDelimiters } from './html-comment-escape.js';
15
16
 
16
17
  // A folded field/inline value that is itself (whole, trimmed) shaped like a FORGE
17
18
  // opening tag or a generic sentinel line has no legitimate reason to look that way —
@@ -40,7 +41,9 @@ const NEWLINE_RE = /\r\n|\r|\n/g;
40
41
  * subsequent lines including the completion sentinel, and treats a literal
41
42
  * `-->` as ending the enclosing comment early, leaking the remainder as
42
43
  * visible rendered text. Both directions of the delimiter pair must be
43
- * escaped. (forge#1594, forge#1638)
44
+ * escaped. (forge#1594, forge#1638) Delegated to the shared
45
+ * `escapeHtmlCommentDelimiters()` helper (forge#2225) so this scheme has a
46
+ * single implementation shared with `bin/engine/state.mjs`.
44
47
  *
45
48
  * As defense in depth, if a value still folds down to a line that is itself a
46
49
  * bare FORGE tag/sentinel shape, emit() rejects it outright rather than silently
@@ -56,16 +59,7 @@ function sanitizeFieldValue(rawValue) {
56
59
  `emit(): field value resolves to a bare FORGE tag/sentinel line, which is not permitted: ${JSON.stringify(rawValue)}`,
57
60
  );
58
61
  }
59
- // Escape the HTML comment opener ("<!--") so a value cannot start a new
60
- // unterminated HTML comment in GitHub's renderer, which would visually swallow
61
- // subsequent lines — the same rendering-leak the closer escaping below prevents
62
- // from the opposite direction. (forge#1638)
63
- const withOpenerEscaped = folded.replace(/<!--/g, '&lt;!--');
64
- // Escape both HTML comment-close forms ("-->" and "--!>") — only replacing the
65
- // literal character sequence (not just documenting the risk) prevents the
66
- // enclosing comment from terminating early. (forge#1594; noted but never fixed
67
- // for the FORGE:STATE codec in bin/engine/state.mjs — see issue investigation)
68
- return withOpenerEscaped.replace(/--(!)?>/g, (_, bang) => `--${bang || ''}&gt;`);
62
+ return escapeHtmlCommentDelimiters(folded);
69
63
  }
70
64
 
71
65
  /**
@@ -0,0 +1,68 @@
1
+ /**
2
+ * Shared HTML-comment-delimiter escape/unescape pair for the FORGE annotation
3
+ * wire format.
4
+ *
5
+ * This scheme is used by two independent callers that must agree byte-for-byte:
6
+ * - `packages/protocol/src/emit.js` / `parse.js` (per-field-value escaping)
7
+ * - `bin/engine/state.mjs` (whole-JSON-payload escaping for the FORGE:STATE
8
+ * compact-index block)
9
+ *
10
+ * Extracting it here removes the drift vector between those two call sites —
11
+ * they previously hand-duplicated this exact regex chain, kept in sync only by
12
+ * cross-referencing code comments, and had already drifted once (forge#1638's
13
+ * fix was applied to `emit.js` before `state.mjs` caught up in forge#2119).
14
+ *
15
+ * @license MIT
16
+ */
17
+
18
+ /**
19
+ * Escape the three HTML comment delimiter forms so a payload/value can never
20
+ * open or close an HTML comment in GitHub's renderer:
21
+ * - `<!--` the comment opener — an unescaped opener would start a new
22
+ * (nested-looking) comment, visually swallowing subsequent content.
23
+ * - `-->` and `--!>` the two comment-close forms browsers/HTML parsers
24
+ * honour. An unescaped closer could end the enclosing comment early,
25
+ * leaking the remainder as visible rendered text (CodeQL js/bad-tag-filter).
26
+ *
27
+ * The `&` escape MUST run first, before either delimiter-escape pass. Without
28
+ * it, a value that already contains literal entity-like text (e.g. `<!--&gt;`
29
+ * or `&lt;!--`) collides with the encoded form of an unrelated real delimiter —
30
+ * the encoder would not be injective, and the collision is unrecoverable on
31
+ * decode (forge#2137). Escaping `&` first guarantees every `&lt;!--`/`--&gt;`/
32
+ * `--!&gt;` sequence in the output was produced by *this* pass, never by
33
+ * pre-existing entity text (which is now doubly-escaped, e.g.
34
+ * `&` -> `&amp;`, left untouched by the delimiter passes that only match the
35
+ * literal `<!--`/`--`+`>` character sequences).
36
+ *
37
+ * @param {string} value
38
+ * @returns {string}
39
+ */
40
+ export function escapeHtmlCommentDelimiters(value) {
41
+ return value
42
+ .replace(/&/g, '&amp;')
43
+ .replace(/<!--/g, '&lt;!--')
44
+ .replace(/--(!)?>/g, (_, bang) => `--${bang || ''}&gt;`);
45
+ }
46
+
47
+ /**
48
+ * Inverse of {@link escapeHtmlCommentDelimiters}. Restores `--&gt;`, `--!&gt;`,
49
+ * `&lt;!--`, then `&amp;` — in that order.
50
+ *
51
+ * Unescape order matters: the closer forms are restored first (most-specific
52
+ * `--!&gt;` before `--&gt;`) so a dangling `!` is never left behind, then the
53
+ * opener is restored. The `&amp;` -> `&` unescape MUST run LAST (mirroring the
54
+ * encode side running it FIRST) so it never touches the `&` that is part of a
55
+ * just-restored `<!--`/`-->`/`--!>` sequence (forge#2137; forge#1662 — parse()
56
+ * previously failed to apply the exact inverse of emit()'s escape, corrupting
57
+ * round-trips).
58
+ *
59
+ * @param {string} raw
60
+ * @returns {string}
61
+ */
62
+ export function unescapeHtmlCommentDelimiters(raw) {
63
+ return raw
64
+ .replace(/--!&gt;/g, '--!>')
65
+ .replace(/--&gt;/g, '-->')
66
+ .replace(/&lt;!--/g, '<!--')
67
+ .replace(/&amp;/g, '&');
68
+ }
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * @forgedock/protocol — FORGE Annotation Protocol v1.0 reference implementation.
3
3
  *
4
- * Provides parse, validate, and emit for all 13 reserved annotation types
4
+ * Provides parse, validate, and emit for all reserved annotation types
5
5
  * defined in the FORGE Annotation Protocol specification (CC-BY-4.0).
6
6
  *
7
7
  * @example
@@ -29,6 +29,8 @@ export { parse } from './parse.js';
29
29
  export { validate } from './validate.js';
30
30
  export { emit, emitPartial, isKnownType } from './emit.js';
31
31
  export { RESERVED_TYPES, RESERVED_TYPE_NAMES, Category, SentinelState } from './types.js';
32
+ export { canonicalJson, toBase64url, encodeCard, decodeCardInlineValue } from './card.js';
33
+ export { PHASE_IDS, PHASE_MARKERS } from './phases.js';
32
34
 
33
35
  /**
34
36
  * @typedef {Object} ParsedAnnotation
@@ -17,6 +17,7 @@
17
17
  */
18
18
 
19
19
  import { RESERVED_TYPES, RESERVED_TYPE_NAMES, SentinelState } from './types.js';
20
+ import { unescapeHtmlCommentDelimiters } from './html-comment-escape.js';
20
21
 
21
22
  // Matches: <!-- FORGE:TYPE --> or <!-- FORGE:TYPE: value -->
22
23
  // Anchored to require the tag to be the ENTIRE (trimmed) line — a FORGE-tag-shaped
@@ -101,26 +102,33 @@ function escapeRegExp(str) {
101
102
  /**
102
103
  * Reverse the HTML entity escapes applied by emit()'s sanitizeFieldValue().
103
104
  *
104
- * emit() escapes three HTML comment delimiter sequences to protect GitHub's
105
- * renderer from comment injection:
105
+ * emit() escapes "&" first, then three HTML comment delimiter sequences, to
106
+ * protect GitHub's renderer from comment injection AND to keep the encoding
107
+ * injective (reversible for every input, not just delimiter-free ones):
108
+ * 0. `&` → `&amp;` (forge#2137 — must run first on encode)
106
109
  * 1. `<!--` → `&lt;!--` (forge#1638)
107
110
  * 2. `--!>` → `--!&gt;` (forge#1594)
108
111
  * 3. `-->` → `--&gt;` (forge#1594)
109
112
  *
110
- * parse() must apply the inverse so that round-tripping through emit → parse
111
- * is lossless. Unescape order: `--!&gt;` before `--&gt;` (most-specific first)
112
- * to avoid the `!` being left dangling by an overly-greedy `--&gt;` pass.
113
- * The `&lt;!--` unescape is independent and can run in any relative order.
114
- * (forge#1662)
113
+ * parse() must apply the exact inverse, in reverse order, so that
114
+ * round-tripping through emit → parse is lossless. Unescape order:
115
+ * `--!&gt;` before `--&gt;` (most-specific first) to avoid the `!` being left
116
+ * dangling by an overly-greedy `--&gt;` pass; `&lt;!--` can run in any order
117
+ * relative to those two. The `&amp;` → `&` unescape MUST run LAST — doing it
118
+ * first (or interleaved) would corrupt the `&` that is part of a freshly
119
+ * restored `<!--`/`-->`/`--!>` sequence. Running "&" first on encode and last
120
+ * on decode is what makes the scheme injective: any pre-existing entity-like
121
+ * text in the original value (e.g. `&lt;!--` or `<!--&gt;`) is doubly-escaped
122
+ * at encode time and can never collide with the single-escaped form produced
123
+ * by a real delimiter (forge#2137; forge#1662). Delegated to the shared
124
+ * `unescapeHtmlCommentDelimiters()` helper (forge#2225) so this scheme has a
125
+ * single implementation shared with `bin/engine/state.mjs`.
115
126
  *
116
127
  * @param {string} raw
117
128
  * @returns {string}
118
129
  */
119
130
  function unescapeFieldValue(raw) {
120
- return raw
121
- .replace(/--!&gt;/g, '--!>')
122
- .replace(/--&gt;/g, '-->')
123
- .replace(/&lt;!--/g, '<!--');
131
+ return unescapeHtmlCommentDelimiters(raw);
124
132
  }
125
133
 
126
134
  /**
@@ -0,0 +1,105 @@
1
+ /**
2
+ * Canonical FORGE pipeline phase table — single source of truth for phase ids
3
+ * (in dispatch order) and the exact marker/label strings that indicate each
4
+ * phase has committed.
5
+ *
6
+ * Consumers: `bin/engine/phases.mjs` (the authoritative engine gate) and
7
+ * `bin/hooks/interactive-engine.mjs` (the interactive-session SubagentStop
8
+ * hook) both import this module instead of hand-declaring their own copies
9
+ * of the marker strings. Before this module existed, the two files carried
10
+ * independent literal copies of the same strings and drifted out of sync at
11
+ * least twice (forge#1669 established the strict `:COMPLETE` gate for
12
+ * `context`/`architect`; forge#2375/PR#2395 then had to hand-patch the hook's
13
+ * copy back into agreement with the engine's). See forge#2378.
14
+ *
15
+ * `completionMarker` values are sourced from `RESERVED_TYPES[*].completionSentinel`
16
+ * wherever that reserved annotation type exists (INVESTIGATOR, CONTEXT,
17
+ * ARCHITECT, BUILDER) — so the reserved-annotation registry in `types.js` and
18
+ * this phase registry cannot drift apart for those four phases; there is
19
+ * exactly one literal declaration of each of those marker strings in the
20
+ * entire codebase, in `types.js`.
21
+ *
22
+ * `review` and `close` have no corresponding `RESERVED_TYPES` entry with a
23
+ * matching sentinel today (`REVIEWER` has no `completionSentinel` defined in
24
+ * spec §4.1; `close` is gated on a GitHub *label*, not a comment annotation),
25
+ * so those two are declared directly in this file — still single-sourced
26
+ * across the two consumers, just not additionally derived from `types.js`.
27
+ *
28
+ * @license MIT
29
+ */
30
+
31
+ import { RESERVED_TYPES } from './types.js';
32
+
33
+ /** Phase ids, in canonical dispatch order (mirrors `commands/work-on.md`'s
34
+ * "Universal Phase Dispatcher" table and `bin/engine/phases.mjs`'s `PHASES`
35
+ * array).
36
+ *
37
+ * `decompose` and `remediate` (forge#2379) are branch phases, not part of the
38
+ * six-phase linear happy path — `decompose` is reached only when `investigate`
39
+ * signals `DECOMPOSE:YES`, and `remediate` only when `review` escalates to
40
+ * `needs-human`. They are still declared here, in the exact same array
41
+ * position `bin/engine/phases.mjs`'s `PHASES` array declares them, because
42
+ * `scripts/check-phase-registry-drift.mjs` requires the two lists to match
43
+ * index-for-index — see that file's own doc comment. */
44
+ export const PHASE_IDS = ['investigate', 'decompose', 'context', 'architect', 'build', 'review', 'remediate', 'close'];
45
+
46
+ /**
47
+ * @typedef {Object} PhaseMarkerEntry
48
+ * @property {string} [completionMarker] - marker/sentinel string (or label, for `close`)
49
+ * whose presence means this phase has committed.
50
+ * @property {string} [invalidMarker] - (investigate only) marks the issue INVALID.
51
+ * @property {string} [decomposedMarker] - (investigate only) marks the issue DECOMPOSED.
52
+ * @property {string} [partialMarker] - sentinel for an interrupted/partial annotation.
53
+ * @property {string} [presenceMarker] - bare annotation-opener substring, used only by
54
+ * phases whose completion is non-critical (see `context` below) — presence alone
55
+ * (without `:COMPLETE`) is enough to report a soft/visible skip rather than a hard fail.
56
+ * @property {string} [completionLabel] - a GitHub issue *label* (not a comment marker)
57
+ * whose presence means this phase has committed. Only `close` uses this form.
58
+ */
59
+
60
+ /** @type {Object.<string, PhaseMarkerEntry>} */
61
+ export const PHASE_MARKERS = {
62
+ investigate: {
63
+ completionMarker: RESERVED_TYPES.INVESTIGATOR.completionSentinel, // 'INVESTIGATION:COMPLETE'
64
+ invalidMarker: 'INVESTIGATION:INVALID',
65
+ decomposedMarker: 'DECOMPOSE:YES',
66
+ },
67
+ // forge#2379: `decompose` actually runs work-on/decompose (sub-issue
68
+ // fan-out) once `investigate` hands off on `DECOMPOSE:YES` — see
69
+ // bin/engine/phases.mjs's `decompose` phase entry for the handoff mechanics.
70
+ decompose: {
71
+ completionMarker: RESERVED_TYPES.DECOMPOSED.completionSentinel, // 'FORGE:DECOMPOSED:COMPLETE'
72
+ },
73
+ context: {
74
+ completionMarker: RESERVED_TYPES.CONTEXT.completionSentinel, // 'FORGE:CONTEXT:COMPLETE'
75
+ partialMarker: RESERVED_TYPES.CONTEXT.partialSentinel, // 'FORGE:CONTEXT:PARTIAL'
76
+ // Bare presence marker: context is documented as non-critical (spec §7) — a
77
+ // missing completion marker is a visible skip, not a hard fail. Only
78
+ // bin/engine/phases.mjs's `context.detectOutcome` uses this bare form;
79
+ // `context.reconcile` (and everything in interactive-engine.mjs) uses the
80
+ // strict `completionMarker` above. Do not collapse these two fields.
81
+ presenceMarker: `FORGE:${RESERVED_TYPES.CONTEXT.type}`, // 'FORGE:CONTEXT'
82
+ },
83
+ architect: {
84
+ completionMarker: RESERVED_TYPES.ARCHITECT.completionSentinel, // 'FORGE:ARCHITECT:COMPLETE'
85
+ partialMarker: RESERVED_TYPES.ARCHITECT.partialSentinel, // 'FORGE:ARCHITECT:PARTIAL'
86
+ },
87
+ build: {
88
+ completionMarker: RESERVED_TYPES.BUILDER.completionSentinel, // 'FORGE:BUILDER:COMPLETE'
89
+ },
90
+ review: {
91
+ // No RESERVED_TYPES entry defines this sentinel (REVIEWER has no
92
+ // completionSentinel in spec §4.1) — declared directly here.
93
+ completionMarker: 'FORGE:REVIEWER:MERGED',
94
+ },
95
+ // forge#2379: `remediate` re-drives a needs-human PR (commands/work-on/remediate.md).
96
+ // Posted to BOTH the PR and the linked issue (Phase M8) — bin/engine/phases.mjs's
97
+ // `remediate` phase reads it off the issue, consistent with every other phase here.
98
+ remediate: {
99
+ completionMarker: RESERVED_TYPES.REMEDIATION.completionSentinel, // 'FORGE:REMEDIATION:COMPLETE'
100
+ },
101
+ close: {
102
+ // A GitHub label, not a comment marker — see `completionLabel` above.
103
+ completionLabel: 'workflow:merged',
104
+ },
105
+ };
@@ -18,7 +18,7 @@ export const SentinelState = {
18
18
  };
19
19
 
20
20
  /**
21
- * All 16 reserved annotation types from spec §4.
21
+ * All reserved annotation types from spec §4.
22
22
  * Spec ref: §4.1 lifecycle, §4.2 cross-artifact, §4.3 control/error markers.
23
23
  * Added CLAIM and CLAIM_RELEASED in forge#1736 — claims board for claim-level parallelism.
24
24
  * Added AUTOPILOT_CYCLE in forge#1753 — durable cycle state for /autopilot.
@@ -86,6 +86,23 @@ export const RESERVED_TYPES = {
86
86
  requiredFields: ['Verdict'],
87
87
  verdictValues: ['APPROVED', 'CHANGES_REQUESTED', 'COMMENTED'],
88
88
  },
89
+ /**
90
+ * REMEDIATION — paper trail for a needs-human PR re-driven by
91
+ * commands/work-on/remediate.md. Posted to BOTH the PR and the linked
92
+ * issue (Phase M8) once the re-gate outcome (AUTO-LANDED /
93
+ * HELD-AWAITING-MERGE / RE-ESCALATED / UNFIXABLE) is known. Added in
94
+ * forge#2379 to single-source the completion sentinel that
95
+ * bin/engine/phases.mjs's `remediate` phase gates on. <!-- Added: forge#2379 -->
96
+ */
97
+ REMEDIATION: {
98
+ type: 'REMEDIATION',
99
+ category: Category.LIFECYCLE,
100
+ completionSentinel: 'FORGE:REMEDIATION:COMPLETE',
101
+ partialSentinel: null,
102
+ inlineValue: false,
103
+ requiredFields: [],
104
+ reGateOutcomeValues: ['AUTO-LANDED', 'HELD-AWAITING-MERGE', 'RE-ESCALATED', 'UNFIXABLE'],
105
+ },
89
106
  TRAJECTORY: {
90
107
  type: 'TRAJECTORY',
91
108
  category: Category.LIFECYCLE,
@@ -8,12 +8,14 @@
8
8
  * - Required field presence (§4.1 per-type field tables)
9
9
  * - Required field enum values (Verdict, Confidence, Severity, Task Type)
10
10
  * - Inline-value non-empty (§3.4)
11
+ * - CARD inline-value decodability + sha8 integrity (§4.2 — see card.js)
11
12
  * - Partial sentinel marking (§3.3) — generates a warning, not an error
12
13
  *
13
14
  * @license MIT
14
15
  */
15
16
 
16
17
  import { RESERVED_TYPES, SentinelState } from './types.js';
18
+ import { decodeCardInlineValue } from './card.js';
17
19
 
18
20
  /**
19
21
  * Validate a parsed annotation against the spec.
@@ -46,6 +48,15 @@ export function validate(annotation) {
46
48
  if (typeDef.inlineValue) {
47
49
  if (inlineValue === null || inlineValue === '') {
48
50
  errors.push(`Inline-value annotation "${type}" must carry a non-empty value (§3.4)`);
51
+ } else if (type === 'CARD') {
52
+ // CARD-specific (§4.2): the inline value must decode via the Base64url
53
+ // codec and pass its sha8 integrity check. A malformed encoding or a
54
+ // tampered/corrupted payload must not be treated as a valid annotation.
55
+ if (decodeCardInlineValue(inlineValue) === null) {
56
+ errors.push(
57
+ `Inline-value "CARD" annotation failed to decode or failed its sha8 integrity check (§4.2)`,
58
+ );
59
+ }
49
60
  }
50
61
  return { valid: errors.length === 0, errors, warnings };
51
62
  }