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.
- package/README.md +8 -2
- package/bin/batch-runner.mjs +31 -2
- package/bin/engine/admission.mjs +294 -0
- package/bin/engine/admission.test.mjs +265 -0
- package/bin/engine/invariants.mjs +17 -4
- package/bin/engine/phases.mjs +358 -42
- package/bin/engine/resolve.mjs +256 -0
- package/bin/engine/resolve.test.mjs +149 -0
- package/bin/engine/state.mjs +31 -7
- package/bin/engine-cli.mjs +138 -2
- package/bin/engine.mjs +414 -21
- package/bin/forgedock.mjs +552 -44
- package/bin/hooks/interactive-engine.mjs +50 -24
- package/bin/hooks/pre-tool-use.mjs +189 -19
- package/bin/journey.mjs +109 -11
- package/bin/labels.json +5 -0
- package/bin/runner.mjs +321 -10
- package/bin/watch-utils.mjs +255 -0
- package/commands/cleanup.md +7 -3
- package/commands/forge-stats.md +5 -4
- package/commands/issue.md +5 -1
- package/commands/orchestrate/config.md +41 -1
- package/commands/orchestrate/phase-1-resolve.md +234 -8
- package/commands/orchestrate/phase-3-dependency.md +27 -18
- package/commands/orchestrate/phase-4-execution.md +223 -22
- package/commands/qa-sweep.md +17 -0
- package/commands/quality-gate.md +6 -6
- package/commands/replay.md +16 -2
- package/commands/review-pr-agents/spec-cli.md +179 -0
- package/commands/review-pr.md +65 -0
- package/commands/upgrade-deps.md +523 -0
- package/commands/work-on/build/validate.md +61 -1
- package/commands/work-on/build.md +9 -1
- package/commands/work-on/decompose.md +10 -1
- package/commands/work-on/investigate.md +22 -4
- package/commands/work-on/remediate.md +2 -0
- package/commands/work-on/review.md +40 -1
- package/commands/work-on.md +19 -4
- package/docs/AIDER.md +264 -0
- package/docs/AUTOPILOT-CRON.md +130 -0
- package/docs/CONFIG.md +156 -0
- package/docs/CURSOR.md +135 -0
- package/docs/OPENCODE.md +312 -0
- package/docs/demo.gif +0 -0
- package/docs/eval/README.md +4 -4
- package/docs/launch/show-hn.md +3 -3
- package/docs/site/architecture.md +180 -0
- package/docs/site/command-reference.md +17 -0
- package/docs/site/configuration.md +243 -0
- package/docs/site/for-companies.md +4 -4
- package/docs/site/forge-annotation-protocol.md +1 -1
- package/docs/site/github-is-the-memory.md +2 -2
- package/docs/site/how-it-works.md +1 -1
- package/docs/site/public/llms-full.txt +380 -0
- package/docs/site/public/llms.txt +35 -0
- package/docs/spec/eval-run-result.md +8 -4
- package/docs/spec/label-state-machine.md +9 -0
- package/docs/spec-graph-schema.md +3 -3
- package/package.json +3 -3
- package/packages/protocol/README.md +75 -0
- package/packages/protocol/src/card.js +114 -0
- package/packages/protocol/src/cli.js +4 -77
- package/packages/protocol/src/emit.js +5 -11
- package/packages/protocol/src/html-comment-escape.js +68 -0
- package/packages/protocol/src/index.js +3 -1
- package/packages/protocol/src/parse.js +19 -11
- package/packages/protocol/src/phases.js +105 -0
- package/packages/protocol/src/types.js +18 -1
- package/packages/protocol/src/validate.js +11 -0
- package/scripts/check-admission-parity.mjs +167 -0
- package/scripts/check-command-side-effects.sh +376 -0
- package/scripts/check-phase-registry-drift.mjs +96 -0
- package/scripts/check-spec-markers.sh +274 -0
- package/scripts/classify-lane.sh +29 -7
- package/scripts/forge-run.sh +353 -0
- package/templates/cross-repo-trigger.yml +282 -0
- package/templates/cursor/forge.mdc +148 -0
- 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
|
|
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:
|
|
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
|
-
|
|
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, '<!--');
|
|
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 || ''}>`);
|
|
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. `<!-->`
|
|
29
|
+
* or `<!--`) 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 `<!--`/`-->`/
|
|
32
|
+
* `--!>` sequence in the output was produced by *this* pass, never by
|
|
33
|
+
* pre-existing entity text (which is now doubly-escaped, e.g.
|
|
34
|
+
* `&` -> `&`, 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, '&')
|
|
43
|
+
.replace(/<!--/g, '<!--')
|
|
44
|
+
.replace(/--(!)?>/g, (_, bang) => `--${bang || ''}>`);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Inverse of {@link escapeHtmlCommentDelimiters}. Restores `-->`, `--!>`,
|
|
49
|
+
* `<!--`, then `&` — in that order.
|
|
50
|
+
*
|
|
51
|
+
* Unescape order matters: the closer forms are restored first (most-specific
|
|
52
|
+
* `--!>` before `-->`) so a dangling `!` is never left behind, then the
|
|
53
|
+
* opener is restored. The `&` -> `&` 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(/--!>/g, '--!>')
|
|
65
|
+
.replace(/-->/g, '-->')
|
|
66
|
+
.replace(/<!--/g, '<!--')
|
|
67
|
+
.replace(/&/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
|
|
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
|
|
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. `&` → `&` (forge#2137 — must run first on encode)
|
|
106
109
|
* 1. `<!--` → `<!--` (forge#1638)
|
|
107
110
|
* 2. `--!>` → `--!>` (forge#1594)
|
|
108
111
|
* 3. `-->` → `-->` (forge#1594)
|
|
109
112
|
*
|
|
110
|
-
* parse() must apply the inverse
|
|
111
|
-
* is lossless. Unescape order:
|
|
112
|
-
* to avoid the `!` being left
|
|
113
|
-
*
|
|
114
|
-
*
|
|
113
|
+
* parse() must apply the exact inverse, in reverse order, so that
|
|
114
|
+
* round-tripping through emit → parse is lossless. Unescape order:
|
|
115
|
+
* `--!>` before `-->` (most-specific first) to avoid the `!` being left
|
|
116
|
+
* dangling by an overly-greedy `-->` pass; `<!--` can run in any order
|
|
117
|
+
* relative to those two. The `&` → `&` 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. `<!--` or `<!-->`) 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(/--!>/g, '--!>')
|
|
122
|
-
.replace(/-->/g, '-->')
|
|
123
|
-
.replace(/<!--/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
|
|
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
|
}
|