forge-workflow 0.1.0-beta.2 → 0.1.0-beta.3
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/.forge/hooks/check-tdd.js +79 -5
- package/.forge/hooks/forge-native-hook.js +194 -8
- package/AGENTS.md +1 -0
- package/CHANGELOG.md +28 -0
- package/QUICKSTART.md +6 -2
- package/README.md +3 -1
- package/bin/forge.js +90 -19
- package/docs/guides/SETUP.md +4 -1
- package/docs/guides/SUPPORT.md +5 -0
- package/docs/reference/COMMANDS.md +9 -0
- package/docs/reference/shepherd.md +42 -2
- package/lib/activation/ensure-forge-home.js +135 -0
- package/lib/adapters/beads-kernel-compat.js +67 -0
- package/lib/adoption-profiles.js +17 -4
- package/lib/beads-detect.js +60 -0
- package/lib/beads-nudge.js +91 -0
- package/lib/commands/_aliases.js +248 -0
- package/lib/commands/_issue.js +39 -0
- package/lib/commands/_manifest.js +2 -0
- package/lib/commands/_registry.js +14 -0
- package/lib/commands/_resolve-command-opts.js +0 -31
- package/lib/commands/gate.js +19 -2
- package/lib/commands/hooks.js +139 -4
- package/lib/commands/init.js +26 -20
- package/lib/commands/memory.js +81 -0
- package/lib/commands/migrate.js +0 -161
- package/lib/commands/plan.js +48 -8
- package/lib/commands/pr.js +88 -0
- package/lib/commands/push.js +66 -0
- package/lib/commands/recall.js +67 -12
- package/lib/commands/recap.js +18 -4
- package/lib/commands/release.js +14 -1
- package/lib/commands/remember.js +86 -20
- package/lib/commands/setup.js +135 -72
- package/lib/commands/shepherd.js +67 -2
- package/lib/commands/ship.js +40 -4
- package/lib/commands/worktree.js +60 -4
- package/lib/core/runtime-graph.js +34 -3
- package/lib/gate-events.js +54 -55
- package/lib/global-flags.js +30 -0
- package/lib/grounding/context-events.js +230 -0
- package/lib/grounding/read-first.js +112 -0
- package/lib/hook-renderer.js +93 -3
- package/lib/kernel/backing-issue.js +7 -1
- package/lib/kernel/owned-kernel.js +43 -0
- package/lib/kernel/sqlite-driver.js +37 -1
- package/lib/pr-monitor/auto-actions.js +175 -0
- package/lib/pr-monitor/digest.js +206 -0
- package/lib/pr-monitor/render-sticky.js +43 -8
- package/lib/pr-monitor/upsert-sticky.js +169 -0
- package/lib/pr-pull.js +43 -2
- package/lib/release-readiness.js +17 -1
- package/lib/upgrade-safety.js +53 -1
- package/lib/workflow/enforce-stage.js +59 -2
- package/package.json +2 -2
- package/scripts/pr-auto-actions.js +93 -0
- package/scripts/pr-verdict-label.js +50 -0
package/lib/commands/recall.js
CHANGED
|
@@ -4,22 +4,34 @@ const memoryRouter = require('../memory/router');
|
|
|
4
4
|
const { stripGlobalFlags } = require('../global-flags');
|
|
5
5
|
const { fenceUntrusted } = require('../untrusted-content');
|
|
6
6
|
|
|
7
|
-
const usage = 'Usage: forge recall [query] [--limit N] [--all] [--json]';
|
|
7
|
+
const usage = 'Usage: forge recall [query] [--kind <type>] [--limit N] [--all] [--json]';
|
|
8
|
+
|
|
9
|
+
// Reserved tag prefix that `remember --kind` writes (kernel issue 8cc1db4d). A `--kind`
|
|
10
|
+
// filter keeps only notes carrying this tag; the prefix is stripped when surfacing the
|
|
11
|
+
// derived `type` field so a note's user tags stay clean. The filter FLAG is `--kind` (NOT
|
|
12
|
+
// `--type`): `--type` is a reserved GLOBAL flag hard-validated to workflow classifications.
|
|
13
|
+
const TYPE_TAG_PREFIX = 'type:';
|
|
14
|
+
|
|
15
|
+
// When a `--kind` filter is active the tag filter runs in the command layer (the store is
|
|
16
|
+
// not reimplemented), so scan a generous window of recent notes before filtering rather than
|
|
17
|
+
// the small default page — otherwise the type match could fall outside the default limit.
|
|
18
|
+
const TYPE_FILTER_SCAN = 1000;
|
|
8
19
|
|
|
9
20
|
/**
|
|
10
|
-
* Separate the optional positional query from `--limit N
|
|
11
|
-
* Global flags (e.g. `-p <dir>`, `--all`) are stripped first so they never
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
21
|
+
* Separate the optional positional query from `--kind <type>`, `--limit N`, and `--json`.
|
|
22
|
+
* Global flags (e.g. `-p <dir>`, `--all`) are stripped first so they never corrupt the
|
|
23
|
+
* search query (kernel issue c1e090ff). `--all` is a GLOBAL boolean flag, so the handler
|
|
24
|
+
* reads it from its `flags` argument (or the raw args on a direct call) — not from here.
|
|
25
|
+
* `--kind` is NOT a global flag, so it survives the strip and is parsed here.
|
|
15
26
|
*
|
|
16
27
|
* @param {string[]} rawArgs - Raw command arguments.
|
|
17
|
-
* @returns {{ query: string, limit: (number|undefined), json: boolean }}
|
|
28
|
+
* @returns {{ query: string, limit: (number|undefined), type: (string|undefined), json: boolean }}
|
|
18
29
|
*/
|
|
19
30
|
function parseArgs(rawArgs) {
|
|
20
31
|
const args = stripGlobalFlags(rawArgs);
|
|
21
32
|
const words = [];
|
|
22
33
|
let limit;
|
|
34
|
+
let type;
|
|
23
35
|
let json = false;
|
|
24
36
|
|
|
25
37
|
for (let index = 0; index < args.length; index += 1) {
|
|
@@ -37,18 +49,43 @@ function parseArgs(rawArgs) {
|
|
|
37
49
|
if (Number.isInteger(value) && value > 0) {
|
|
38
50
|
limit = value;
|
|
39
51
|
}
|
|
52
|
+
} else if (arg === '--kind') {
|
|
53
|
+
const value = args[index + 1];
|
|
54
|
+
if (value && !value.startsWith('--')) {
|
|
55
|
+
type = value.trim();
|
|
56
|
+
index += 1;
|
|
57
|
+
}
|
|
58
|
+
} else if (arg.startsWith('--kind=')) {
|
|
59
|
+
type = arg.slice('--kind='.length).trim();
|
|
40
60
|
} else {
|
|
41
61
|
words.push(arg);
|
|
42
62
|
}
|
|
43
63
|
}
|
|
44
64
|
|
|
45
|
-
return { query: words.join(' ').trim(), limit, json };
|
|
65
|
+
return { query: words.join(' ').trim(), limit, type, json };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Derive a note's `type` from its reserved `type:` tag (undefined when untyped), so any
|
|
69
|
+
// surface — JSON and text — can show and filter by kind without exposing the tag encoding.
|
|
70
|
+
function typeOf(entry) {
|
|
71
|
+
const tag = (entry.tags || []).find(t => t.startsWith(TYPE_TAG_PREFIX));
|
|
72
|
+
return tag ? tag.slice(TYPE_TAG_PREFIX.length) : undefined;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function withType(entry) {
|
|
76
|
+
const type = typeOf(entry);
|
|
77
|
+
return type ? { ...entry, type } : entry;
|
|
46
78
|
}
|
|
47
79
|
|
|
48
80
|
function formatEntry(entry) {
|
|
49
81
|
const date = entry.timestamp ? entry.timestamp.slice(0, 10) : '';
|
|
50
82
|
const prefix = date ? `${date} ` : '';
|
|
51
|
-
|
|
83
|
+
// The reserved `type:` tag renders as a leading `(kind)` marker, not as a raw tag, so the
|
|
84
|
+
// displayed tags stay the user's own labels.
|
|
85
|
+
const type = typeOf(entry);
|
|
86
|
+
const userTags = (entry.tags || []).filter(t => !t.startsWith(TYPE_TAG_PREFIX));
|
|
87
|
+
const tagSuffix = userTags.length > 0 ? ` [${userTags.join(', ')}]` : '';
|
|
88
|
+
const typeMarker = type ? `(${type}) ` : '';
|
|
52
89
|
// Machine/insights records are LABELED with their source so they are never mistaken for a
|
|
53
90
|
// plain human note; human `remember` notes render clean.
|
|
54
91
|
const marker = entry.machine && entry.sourceAgent ? `(${entry.sourceAgent}) ` : '';
|
|
@@ -56,16 +93,33 @@ function formatEntry(entry) {
|
|
|
56
93
|
// so the human/agent-facing render is provenance-fenced. The `--json` path above
|
|
57
94
|
// keeps the raw note so programmatic consumers/parsers are unaffected.
|
|
58
95
|
const note = fenceUntrusted(entry.note, { source: 'memory' });
|
|
59
|
-
return `- ${prefix}${marker}${note}${tagSuffix}`;
|
|
96
|
+
return `- ${prefix}${marker}${typeMarker}${note}${tagSuffix}`;
|
|
60
97
|
}
|
|
61
98
|
|
|
62
99
|
async function handler(args, flags, projectRoot) {
|
|
63
|
-
const { query, limit, json } = parseArgs(args);
|
|
100
|
+
const { query, limit, type, json } = parseArgs(args);
|
|
64
101
|
// `--all` is a GLOBAL boolean flag: in production bin/forge.js strips it from
|
|
65
102
|
// args and sets flags.all; on a direct handler call it may still be in args.
|
|
66
103
|
const all = Boolean(flags && flags.all) || args.includes('--all');
|
|
67
104
|
|
|
68
|
-
|
|
105
|
+
// A `--type` filter scans a generous recent window, then keeps only matching notes — the
|
|
106
|
+
// read stays entirely in the existing store (no schema change). `--limit` is re-applied
|
|
107
|
+
// AFTER filtering so it caps the typed result set, not the pre-filter scan.
|
|
108
|
+
const recallLimit = type ? Math.max(limit ?? 0, TYPE_FILTER_SCAN) : limit;
|
|
109
|
+
const result = memoryRouter.recall(projectRoot, { query, limit: recallLimit, all });
|
|
110
|
+
let notes = result.notes.map(withType);
|
|
111
|
+
let { total, capped } = result;
|
|
112
|
+
const { scope } = result;
|
|
113
|
+
if (type) {
|
|
114
|
+
notes = notes.filter(entry => entry.type === type);
|
|
115
|
+
total = notes.length;
|
|
116
|
+
if (limit && notes.length > limit) {
|
|
117
|
+
notes = notes.slice(0, limit);
|
|
118
|
+
capped = true;
|
|
119
|
+
} else {
|
|
120
|
+
capped = false;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
69
123
|
|
|
70
124
|
if (json) {
|
|
71
125
|
// Object (not a bare array) so programmatic consumers see the total and whether the
|
|
@@ -108,6 +162,7 @@ module.exports = {
|
|
|
108
162
|
description: 'Retrieve project-memory notes from the kernel-backed memory store',
|
|
109
163
|
usage,
|
|
110
164
|
flags: {
|
|
165
|
+
'--kind': 'Filter to notes of a type (decision|bugfix|gotcha|...); --type is reserved',
|
|
111
166
|
'--limit': 'Cap the number of notes returned',
|
|
112
167
|
'--all': 'Include machine/insights records in the no-query listing (query already searches all)',
|
|
113
168
|
'--json': 'Emit machine-readable JSON output',
|
package/lib/commands/recap.js
CHANGED
|
@@ -4,6 +4,7 @@ const {
|
|
|
4
4
|
buildIssueRecap,
|
|
5
5
|
formatOrientationText,
|
|
6
6
|
} = require('../orientation');
|
|
7
|
+
const { recordContextLoaded } = require('../grounding/context-events');
|
|
7
8
|
|
|
8
9
|
const usage = 'Usage: forge recap <issue> [--budget N] [--json]';
|
|
9
10
|
|
|
@@ -35,7 +36,7 @@ function readIssueArg(args) {
|
|
|
35
36
|
|
|
36
37
|
// `forge recap <issue>` summarizes a single issue from the deterministic
|
|
37
38
|
// orientation source assembly — it is issue-scoped, not a project-wide recap.
|
|
38
|
-
async function handler(args, _flags, projectRoot) {
|
|
39
|
+
async function handler(args, _flags, projectRoot, opts = {}) {
|
|
39
40
|
const issueId = readIssueArg(args);
|
|
40
41
|
if (!issueId) {
|
|
41
42
|
// Use `error` (not `output`) so the CLI dispatcher prints the usage line
|
|
@@ -44,9 +45,22 @@ async function handler(args, _flags, projectRoot) {
|
|
|
44
45
|
return { success: false, error: usage };
|
|
45
46
|
}
|
|
46
47
|
|
|
47
|
-
const
|
|
48
|
-
|
|
49
|
-
|
|
48
|
+
const budget = readOption(args, '--budget', undefined);
|
|
49
|
+
const recap = buildIssueRecap(projectRoot, issueId, { budgetTokens: budget });
|
|
50
|
+
|
|
51
|
+
// Grounding (gate.read_first): a successful recap IS the load-the-doc action,
|
|
52
|
+
// so append a `context.loaded` event that unblocks a later `forge claim <id>`.
|
|
53
|
+
// Best-effort and awaited so the event is durable before the CLI process exits;
|
|
54
|
+
// a bookkeeping failure never fails the recap render.
|
|
55
|
+
const deps = (opts.kernelBroker && opts.kernelDriver)
|
|
56
|
+
? { kernelBroker: opts.kernelBroker, kernelDriver: opts.kernelDriver }
|
|
57
|
+
: undefined;
|
|
58
|
+
try {
|
|
59
|
+
await recordContextLoaded(projectRoot, {
|
|
60
|
+
issueId, cmd: 'recap', budget, session: opts.session, env: opts.env, deps, now: opts.now,
|
|
61
|
+
});
|
|
62
|
+
} catch { /* best-effort: never fail a recap on grounding bookkeeping */ }
|
|
63
|
+
|
|
50
64
|
return {
|
|
51
65
|
success: true,
|
|
52
66
|
output: args.includes('--json') ? `${JSON.stringify(recap, null, 2)}\n` : formatOrientationText(recap),
|
package/lib/commands/release.js
CHANGED
|
@@ -4,6 +4,7 @@ const {
|
|
|
4
4
|
SUPPORTED_TARGET,
|
|
5
5
|
buildReadinessReport,
|
|
6
6
|
renderReadinessReport,
|
|
7
|
+
writeAuditArtifact,
|
|
7
8
|
} = require('../release-readiness');
|
|
8
9
|
const { runIssueOperation: defaultRunIssueOperation } = require('../forge-issues');
|
|
9
10
|
const { normalizeArgs, normalizeIssueResult, withResolvedIssueBackend } = require('./_issue');
|
|
@@ -13,7 +14,7 @@ const { normalizeArgs, normalizeIssueResult, withResolvedIssueBackend } = requir
|
|
|
13
14
|
// dispatches `check` to the gate and routes everything else through the shared
|
|
14
15
|
// issue dispatch (resolve backend → runIssueOperation('release') → normalize). The
|
|
15
16
|
// Beads backend has no release op and returns the Kernel-only contract error.
|
|
16
|
-
const usage = 'Usage: forge release <id> | forge release check --target 0.1.0 [--json]';
|
|
17
|
+
const usage = 'Usage: forge release <id> | forge release check --target 0.1.0 [--json] | forge release regen-audit';
|
|
17
18
|
|
|
18
19
|
async function runReleaseIssue(args, projectRoot, opts = {}) {
|
|
19
20
|
const resolved = withResolvedIssueBackend(projectRoot, opts);
|
|
@@ -57,6 +58,18 @@ function parseReleaseArgs(args = []) {
|
|
|
57
58
|
async function handler(args, _flags, projectRoot, opts = {}) {
|
|
58
59
|
const parsed = parseReleaseArgs(args);
|
|
59
60
|
|
|
61
|
+
if (parsed.subcommand === 'regen-audit') {
|
|
62
|
+
// forge release regen-audit — rewrite the D20 kill-list from a live re-scan.
|
|
63
|
+
// The staleness gate (lib/release-readiness d20 check) points here so a
|
|
64
|
+
// Beads-removal PR that shifts the census is a one-command fix, not a
|
|
65
|
+
// hand-edit that red-fails CI until it matches byte-for-byte.
|
|
66
|
+
const { path: artifact } = writeAuditArtifact(projectRoot);
|
|
67
|
+
return {
|
|
68
|
+
success: true,
|
|
69
|
+
output: `Regenerated ${artifact}. Commit it to clear the d20 staleness gate.\n`,
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
60
73
|
if (parsed.subcommand !== 'check') {
|
|
61
74
|
// forge release <id> — release a claimed issue via the shared issue dispatch.
|
|
62
75
|
return runReleaseIssue(args, projectRoot, opts);
|
package/lib/commands/remember.js
CHANGED
|
@@ -3,62 +3,122 @@
|
|
|
3
3
|
const memoryRouter = require('../memory/router');
|
|
4
4
|
const { stripGlobalFlags } = require('../global-flags');
|
|
5
5
|
|
|
6
|
-
const usage =
|
|
6
|
+
const usage =
|
|
7
|
+
'Usage: forge remember <note> [--kind <type>] [--session-summary] [--tag <label>]... ' +
|
|
8
|
+
'[--what <text>] [--why <text>] [--where <text>] [--learned <text>] [--json]';
|
|
9
|
+
|
|
10
|
+
// Structured note fields (kernel issue 8cc1db4d). Each is optional and, when present, is
|
|
11
|
+
// folded into the stored note body as a labeled line so it stays FTS-searchable. Order is
|
|
12
|
+
// fixed for a stable, readable render.
|
|
13
|
+
const STRUCTURED_FIELDS = [
|
|
14
|
+
['--what', 'What'],
|
|
15
|
+
['--why', 'Why'],
|
|
16
|
+
['--where', 'Where'],
|
|
17
|
+
['--learned', 'Learned'],
|
|
18
|
+
];
|
|
19
|
+
|
|
20
|
+
// A note's type is stored as a reserved `type:<value>` tag — cheap, additive, and filterable
|
|
21
|
+
// by recall/search WITHOUT a store-schema change (a missing type is fine). The CLI flag is
|
|
22
|
+
// `--kind` (NOT `--type`): `--type` is a reserved GLOBAL flag that bin/forge.js hard-validates
|
|
23
|
+
// against workflow classifications (critical|standard|…), so it can never carry a note type.
|
|
24
|
+
const TYPE_TAG_PREFIX = 'type:';
|
|
7
25
|
|
|
8
26
|
/**
|
|
9
|
-
* Split positional note words from `--tag <label
|
|
10
|
-
* Global flags (e.g. `-p <dir>`) are stripped first so they never leak into
|
|
11
|
-
*
|
|
27
|
+
* Split positional note words from `--kind`, `--tag <label>`, the structured field flags,
|
|
28
|
+
* and `--json`. Global flags (e.g. `-p <dir>`) are stripped first so they never leak into the
|
|
29
|
+
* stored note content (kernel issue c1e090ff); `--kind` is NOT a global flag, so it survives
|
|
30
|
+
* the strip and is parsed here.
|
|
12
31
|
*
|
|
13
32
|
* @param {string[]} rawArgs - Raw command arguments.
|
|
14
|
-
* @returns {{ note: string, tags: string[], json: boolean }}
|
|
33
|
+
* @returns {{ note: string, tags: string[], type: (string|undefined), fields: object, json: boolean }}
|
|
15
34
|
*/
|
|
16
35
|
function parseArgs(rawArgs) {
|
|
17
36
|
const args = stripGlobalFlags(rawArgs);
|
|
37
|
+
const fieldFlags = new Map(STRUCTURED_FIELDS.map(([flag, label]) => [flag, label]));
|
|
18
38
|
const words = [];
|
|
19
39
|
const tags = [];
|
|
40
|
+
const fields = {};
|
|
41
|
+
let type;
|
|
20
42
|
let json = false;
|
|
21
43
|
|
|
44
|
+
// Consume `--flag value` / `--flag=value`, guarding against swallowing the next flag.
|
|
45
|
+
const takeValue = (index) => {
|
|
46
|
+
const arg = args[index];
|
|
47
|
+
const eq = arg.indexOf('=');
|
|
48
|
+
if (eq >= 0) return { value: arg.slice(eq + 1), consumed: 0 };
|
|
49
|
+
const next = args[index + 1];
|
|
50
|
+
if (next && !next.startsWith('--')) return { value: next, consumed: 1 };
|
|
51
|
+
return { value: undefined, consumed: 0 };
|
|
52
|
+
};
|
|
53
|
+
|
|
22
54
|
for (let index = 0; index < args.length; index += 1) {
|
|
23
55
|
const arg = args[index];
|
|
56
|
+
const bare = arg.startsWith('--') ? arg.split('=', 1)[0] : arg;
|
|
24
57
|
if (arg === '--json') {
|
|
25
58
|
json = true;
|
|
26
|
-
} else if (arg === '--
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
59
|
+
} else if (arg === '--session-summary') {
|
|
60
|
+
// Memorable one-flag alias for `--kind session-summary` — the explicit capture-on-exit
|
|
61
|
+
// path an agent calls (via `forge memory add --session-summary` / `forge remember`) to
|
|
62
|
+
// persist a structured session summary with the same --what/--why/--learned fields.
|
|
63
|
+
type = 'session-summary';
|
|
64
|
+
} else if (bare === '--kind') {
|
|
65
|
+
const { value, consumed } = takeValue(index);
|
|
66
|
+
if (value) type = value.trim();
|
|
67
|
+
index += consumed;
|
|
68
|
+
} else if (bare === '--tag') {
|
|
69
|
+
const { value, consumed } = takeValue(index);
|
|
70
|
+
if (value) tags.push(value);
|
|
71
|
+
index += consumed;
|
|
72
|
+
} else if (fieldFlags.has(bare)) {
|
|
73
|
+
const { value, consumed } = takeValue(index);
|
|
74
|
+
if (value) fields[fieldFlags.get(bare)] = value.trim();
|
|
75
|
+
index += consumed;
|
|
34
76
|
} else {
|
|
35
77
|
words.push(arg);
|
|
36
78
|
}
|
|
37
79
|
}
|
|
38
80
|
|
|
39
|
-
return { note: words.join(' ').trim(), tags, json };
|
|
81
|
+
return { note: words.join(' ').trim(), tags, type, fields, json };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Compose the stored note body from the positional note plus any structured fields. Fields
|
|
86
|
+
* are appended as labeled lines under the note so recall renders them readably and they stay
|
|
87
|
+
* searchable. Returns the empty string only when nothing at all was provided.
|
|
88
|
+
*/
|
|
89
|
+
function composeBody(note, fields) {
|
|
90
|
+
const lines = STRUCTURED_FIELDS
|
|
91
|
+
.map(([, label]) => (fields[label] ? `${label}: ${fields[label]}` : null))
|
|
92
|
+
.filter(Boolean);
|
|
93
|
+
return [note, ...lines].filter(Boolean).join('\n');
|
|
40
94
|
}
|
|
41
95
|
|
|
42
96
|
async function handler(args, _flags, projectRoot) {
|
|
43
|
-
const { note, tags, json } = parseArgs(args);
|
|
97
|
+
const { note, tags, type, fields, json } = parseArgs(args);
|
|
98
|
+
const body = composeBody(note, fields);
|
|
44
99
|
|
|
45
|
-
if (!
|
|
100
|
+
if (!body) {
|
|
46
101
|
return {
|
|
47
102
|
success: false,
|
|
48
103
|
error: `No note provided.\n${usage}`,
|
|
49
104
|
};
|
|
50
105
|
}
|
|
51
106
|
|
|
52
|
-
|
|
107
|
+
// The type rides as a reserved tag so it is stored and filterable without a schema change.
|
|
108
|
+
const allTags = type ? [...tags, `${TYPE_TAG_PREFIX}${type}`] : tags;
|
|
109
|
+
const entry = memoryRouter.append(projectRoot, body, { tags: allTags });
|
|
53
110
|
|
|
54
111
|
if (json) {
|
|
55
|
-
|
|
112
|
+
const payload = type ? { ...entry, type } : entry;
|
|
113
|
+
return { success: true, output: `${JSON.stringify(payload, null, 2)}\n` };
|
|
56
114
|
}
|
|
57
115
|
|
|
58
|
-
const
|
|
116
|
+
const userTags = entry.tags.filter(tag => !tag.startsWith(TYPE_TAG_PREFIX));
|
|
117
|
+
const tagSuffix = userTags.length > 0 ? ` [${userTags.join(', ')}]` : '';
|
|
118
|
+
const typePrefix = type ? `(${type}) ` : '';
|
|
59
119
|
return {
|
|
60
120
|
success: true,
|
|
61
|
-
output: `Remembered: ${entry.note}${tagSuffix}`,
|
|
121
|
+
output: `Remembered: ${typePrefix}${entry.note}${tagSuffix}`,
|
|
62
122
|
};
|
|
63
123
|
}
|
|
64
124
|
|
|
@@ -67,7 +127,13 @@ module.exports = {
|
|
|
67
127
|
description: 'Persist a project-memory note to the kernel-backed memory store',
|
|
68
128
|
usage,
|
|
69
129
|
flags: {
|
|
130
|
+
'--kind': 'Type the note (decision|bugfix|gotcha|...) — filterable by recall (--type is reserved)',
|
|
131
|
+
'--session-summary': 'Alias for --kind session-summary — capture a session summary on exit',
|
|
70
132
|
'--tag': 'Attach a search label (repeatable)',
|
|
133
|
+
'--what': 'Structured field: what happened/changed',
|
|
134
|
+
'--why': 'Structured field: why',
|
|
135
|
+
'--where': 'Structured field: where (file/area)',
|
|
136
|
+
'--learned': 'Structured field: what was learned',
|
|
71
137
|
'--json': 'Emit machine-readable JSON output',
|
|
72
138
|
},
|
|
73
139
|
handler,
|
package/lib/commands/setup.js
CHANGED
|
@@ -378,13 +378,13 @@ async function finalizeWorkflowConfig(options = {}) {
|
|
|
378
378
|
|
|
379
379
|
/**
|
|
380
380
|
* Default init runner for finalizeWorkflowConfig. When `skipSideEffects` is set
|
|
381
|
-
* (setup paths that already installed git hooks
|
|
382
|
-
*
|
|
383
|
-
*
|
|
381
|
+
* (setup paths that already installed git hooks), pass no-op deps so init only
|
|
382
|
+
* writes `.forge/config.yaml` instead of re-doing that work — avoids duplicate
|
|
383
|
+
* side effects and warning noise (CodeRabbit on PR #368).
|
|
384
384
|
*/
|
|
385
385
|
function defaultRunInit(root, skipSideEffects) {
|
|
386
386
|
const deps = skipSideEffects
|
|
387
|
-
? { installHooks: () => {}
|
|
387
|
+
? { installHooks: () => {} }
|
|
388
388
|
: {};
|
|
389
389
|
return initCommand.handler(['--yes'], {}, root, deps);
|
|
390
390
|
}
|
|
@@ -2734,6 +2734,102 @@ function installForgeHookScripts() {
|
|
|
2734
2734
|
// kept shipping the broken behaviour. They are imported at the top of this file and
|
|
2735
2735
|
// re-exported below for the existing test surface.
|
|
2736
2736
|
|
|
2737
|
+
// Honest, resolved-config description of what the installed hooks will actually
|
|
2738
|
+
// enforce (issue eda6d866). The hook scripts are installed unconditionally but are
|
|
2739
|
+
// INERT when their gate/rail is disabled in .forge/config.yaml, so setup must not
|
|
2740
|
+
// claim "enforcement active" when the resolved config says otherwise.
|
|
2741
|
+
// Resolve — from the SAME .forge/config.yaml the hooks read at run time — whether the TDD
|
|
2742
|
+
// gate is active and how many protected paths are guarded. The reporting/exit layer gates
|
|
2743
|
+
// its verdict on this so setup never claims (or fails over) a state the user deliberately
|
|
2744
|
+
// disabled (issue eda6d866). On resolver THROW (missing/corrupt config), fail TOWARD
|
|
2745
|
+
// enforcement (tddActive:true, resolved:false) so a broken config can never silently
|
|
2746
|
+
// downgrade the human-facing verdict.
|
|
2747
|
+
function resolveHookEnforcementState(root = projectRoot) {
|
|
2748
|
+
try {
|
|
2749
|
+
const { getResolvedRuntimeGraph } = require('../core/runtime-graph');
|
|
2750
|
+
const graph = getResolvedRuntimeGraph({ projectRoot: root });
|
|
2751
|
+
const tddRail = (graph.rails || []).find(rail => rail.id === 'rail.tdd_intent');
|
|
2752
|
+
const tddActive = !tddRail || tddRail.enabled !== false;
|
|
2753
|
+
const protectedCount = Array.isArray(graph.protectedPaths) ? graph.protectedPaths.length : 0;
|
|
2754
|
+
return { tddActive, protectedCount, resolved: true };
|
|
2755
|
+
} catch {
|
|
2756
|
+
return { tddActive: true, protectedCount: 0, resolved: false };
|
|
2757
|
+
}
|
|
2758
|
+
}
|
|
2759
|
+
|
|
2760
|
+
function describeHookEnforcement(root = projectRoot) {
|
|
2761
|
+
const state = resolveHookEnforcementState(root);
|
|
2762
|
+
if (!state.resolved) {
|
|
2763
|
+
return null; // never let an honest-status line block setup (resolver threw)
|
|
2764
|
+
}
|
|
2765
|
+
const tdd = state.tddActive ? 'active' : 'disabled in config (hook inert)';
|
|
2766
|
+
const pp = state.protectedCount > 0
|
|
2767
|
+
? `active (${state.protectedCount} path${state.protectedCount === 1 ? '' : 's'})`
|
|
2768
|
+
: 'disabled in config (hook inert)';
|
|
2769
|
+
return `TDD gate: ${tdd}; protected-path: ${pp}`;
|
|
2770
|
+
}
|
|
2771
|
+
|
|
2772
|
+
// Pure reporting/exit-layer verdict (issue eda6d866): fold the file-presence hook check
|
|
2773
|
+
// (verifyHooksActive) and the resolved config state (resolveHookEnforcementState) into the
|
|
2774
|
+
// message/level and whether setup should exit non-zero. Kept pure + exported so the four
|
|
2775
|
+
// honesty cases are unit-testable without spawning setup. The caller folds "are we in a git
|
|
2776
|
+
// repo" into `loud`, so a non-git dir (nothing to hook into) only ever warns.
|
|
2777
|
+
function buildHookVerdict(verdict, state, options = {}) {
|
|
2778
|
+
const loud = options.loud === true;
|
|
2779
|
+
const tddActive = !state || state.tddActive !== false;
|
|
2780
|
+
if (verdict.active) {
|
|
2781
|
+
if (tddActive) {
|
|
2782
|
+
return {
|
|
2783
|
+
message: ` ✓ Git hook enforcement active (${verdict.method}).`,
|
|
2784
|
+
level: 'log',
|
|
2785
|
+
exitFailure: false,
|
|
2786
|
+
};
|
|
2787
|
+
}
|
|
2788
|
+
// Hooks installed but the TDD gate is OFF in config → the scripts are inert by the
|
|
2789
|
+
// user's own choice. Say so honestly instead of over-claiming "enforcement active".
|
|
2790
|
+
return {
|
|
2791
|
+
message: ` ✓ Git hooks installed (${verdict.method}) — TDD gate disabled in .forge/config.yaml, hooks are inert. Re-enable: forge gate enable rail.tdd_intent`,
|
|
2792
|
+
level: 'log',
|
|
2793
|
+
exitFailure: false,
|
|
2794
|
+
};
|
|
2795
|
+
}
|
|
2796
|
+
if (!tddActive) {
|
|
2797
|
+
// Hooks not active AND the user disabled enforcement — this is the CHOSEN state (e.g.
|
|
2798
|
+
// `init --profile minimal`), so never banner or exit non-zero for it, even under loud.
|
|
2799
|
+
return {
|
|
2800
|
+
message: ' ℹ Git hooks not active — enforcement is disabled in .forge/config.yaml anyway.',
|
|
2801
|
+
level: 'info',
|
|
2802
|
+
exitFailure: false,
|
|
2803
|
+
};
|
|
2804
|
+
}
|
|
2805
|
+
// TDD is ON but hooks are inert — the silent-inert bug B3. Loud setup in a git repo fails
|
|
2806
|
+
// LOUDLY + non-zero; otherwise (init/repair path, or non-git dir folded into !loud) warn.
|
|
2807
|
+
if (loud) {
|
|
2808
|
+
const addCmd = PKG_MANAGER === 'bun'
|
|
2809
|
+
? 'bun add -d'
|
|
2810
|
+
: PKG_MANAGER === 'npm'
|
|
2811
|
+
? 'npm install --save-dev'
|
|
2812
|
+
: `${PKG_MANAGER} add -D`;
|
|
2813
|
+
const message = [
|
|
2814
|
+
'',
|
|
2815
|
+
' ============================================================',
|
|
2816
|
+
' ⚠ TDD ENFORCEMENT IS NOT ACTIVE',
|
|
2817
|
+
` ${verdict.reason || 'no pre-commit hook is installed'}.`,
|
|
2818
|
+
' `forge ship` will block until hooks are active. To fix:',
|
|
2819
|
+
` ${addCmd} lefthook && npx lefthook install`,
|
|
2820
|
+
' (or re-run `forge setup` in the repo root).',
|
|
2821
|
+
' ============================================================',
|
|
2822
|
+
'',
|
|
2823
|
+
].join('\n');
|
|
2824
|
+
return { message, level: 'error', exitFailure: true };
|
|
2825
|
+
}
|
|
2826
|
+
return {
|
|
2827
|
+
message: ` ⚠ TDD enforcement is NOT active: ${verdict.reason || 'no pre-commit hook installed'}.`,
|
|
2828
|
+
level: 'warn',
|
|
2829
|
+
exitFailure: false,
|
|
2830
|
+
};
|
|
2831
|
+
}
|
|
2832
|
+
|
|
2737
2833
|
function installGitHooks(options = {}) { // NOSONAR — Extracted as-is from bin/forge.js; complexity reduction deferred
|
|
2738
2834
|
// loud=true (the `forge setup` handlers — quickSetup + executeSetup) surfaces an
|
|
2739
2835
|
// inert-hooks result as a HARD failure — a banner + non-zero exit — so setup never ends
|
|
@@ -2742,13 +2838,27 @@ function installGitHooks(options = {}) { // NOSONAR — Extracted as-is from bin
|
|
|
2742
2838
|
// init deliberately degrades to a warning rather than failing, and repair runs inside
|
|
2743
2839
|
// another command's flow.
|
|
2744
2840
|
const loud = options.loud === true;
|
|
2745
|
-
|
|
2841
|
+
// Honest header: when the TDD gate is disabled in config the hooks install but are inert,
|
|
2842
|
+
// so don't announce "(TDD enforcement)" for a state the user turned off (issue eda6d866).
|
|
2843
|
+
console.log(
|
|
2844
|
+
resolveHookEnforcementState(projectRoot).tddActive
|
|
2845
|
+
? 'Installing git hooks (TDD enforcement)...'
|
|
2846
|
+
: 'Installing git hooks (TDD gate disabled in config — hooks will be inert)...'
|
|
2847
|
+
);
|
|
2746
2848
|
|
|
2747
2849
|
// Install the Forge hook SCRIPTS first, unconditionally: they back BOTH the lefthook
|
|
2748
2850
|
// pre-commit gate AND the native harness hooks that forge setup renders regardless of
|
|
2749
2851
|
// whether the lefthook binary is present.
|
|
2750
2852
|
installForgeHookScripts();
|
|
2751
2853
|
|
|
2854
|
+
// Report what the hooks will ACTUALLY enforce given the resolved config — a
|
|
2855
|
+
// disabled gate/rail installs an inert hook, so say so instead of over-claiming.
|
|
2856
|
+
const enforcementStatus = describeHookEnforcement();
|
|
2857
|
+
if (enforcementStatus) {
|
|
2858
|
+
console.log(` Enforcement (from .forge/config.yaml): ${enforcementStatus}`);
|
|
2859
|
+
}
|
|
2860
|
+
|
|
2861
|
+
// Skip lefthook.yml creation if binary is not available
|
|
2752
2862
|
const lefthookStatus = checkLefthookStatus(projectRoot);
|
|
2753
2863
|
let lefthookInstalled = false;
|
|
2754
2864
|
if (!lefthookStatus.binaryAvailable && lefthookStatus.message) {
|
|
@@ -2826,29 +2936,24 @@ function installGitHooks(options = {}) { // NOSONAR — Extracted as-is from bin
|
|
|
2826
2936
|
}
|
|
2827
2937
|
|
|
2828
2938
|
const verdict = verifyHooksActive(projectRoot);
|
|
2829
|
-
|
|
2830
|
-
|
|
2831
|
-
|
|
2832
|
-
|
|
2833
|
-
|
|
2834
|
-
|
|
2835
|
-
|
|
2836
|
-
|
|
2837
|
-
|
|
2838
|
-
|
|
2839
|
-
|
|
2840
|
-
|
|
2841
|
-
console.
|
|
2842
|
-
console.error(' ⚠ TDD ENFORCEMENT IS NOT ACTIVE');
|
|
2843
|
-
console.error(` ${verdict.reason || 'no pre-commit hook is installed'}.`);
|
|
2844
|
-
console.error(' `forge ship` will block until hooks are active. To fix:');
|
|
2845
|
-
console.error(` ${addCmd} lefthook && npx lefthook install`);
|
|
2846
|
-
console.error(' (or re-run `forge setup` in the repo root).');
|
|
2847
|
-
console.error(' ============================================================');
|
|
2848
|
-
console.error('');
|
|
2849
|
-
process.exitCode = 1;
|
|
2939
|
+
const state = resolveHookEnforcementState(projectRoot);
|
|
2940
|
+
// A non-git dir has nothing to hook into, so it must only ever WARN (never banner/exit).
|
|
2941
|
+
// Fold that into `loud` before building the verdict so buildHookVerdict stays pure. When
|
|
2942
|
+
// the TDD gate is disabled in config, the verdict is info-level with no failure exit even
|
|
2943
|
+
// under loud setup — enforcement honestly follows the resolved config (issue eda6d866).
|
|
2944
|
+
const inGitRepo = Boolean(resolveGitHooksDir(projectRoot));
|
|
2945
|
+
const built = buildHookVerdict(verdict, state, { loud: loud && inGitRepo });
|
|
2946
|
+
if (built.level === 'error') {
|
|
2947
|
+
console.error(built.message);
|
|
2948
|
+
} else if (built.level === 'warn') {
|
|
2949
|
+
console.warn(built.message);
|
|
2950
|
+
} else if (built.level === 'info') {
|
|
2951
|
+
console.info(built.message);
|
|
2850
2952
|
} else {
|
|
2851
|
-
console.
|
|
2953
|
+
console.log(built.message);
|
|
2954
|
+
}
|
|
2955
|
+
if (built.exitFailure) {
|
|
2956
|
+
process.exitCode = 1;
|
|
2852
2957
|
}
|
|
2853
2958
|
}
|
|
2854
2959
|
|
|
@@ -3258,44 +3363,6 @@ function configureDefaultExternalServices(skipExternal) {
|
|
|
3258
3363
|
console.log('Configuration saved to .env.local');
|
|
3259
3364
|
}
|
|
3260
3365
|
|
|
3261
|
-
// Auto-import an existing Beads store into the Kernel during setup.
|
|
3262
|
-
// Idempotent and CLI-free: reuses the `forge migrate --from beads` spine, which
|
|
3263
|
-
// reads the committed Beads jsonl sidecars directly (no external issue-tracker
|
|
3264
|
-
// binary), so it works even when the legacy SQL backend is offline. Failures
|
|
3265
|
-
// degrade to a setup note rather than aborting setup. Returns the migrate
|
|
3266
|
-
// outcome for callers/tests.
|
|
3267
|
-
async function autoMigrateBeadsToKernel(opts = {}) {
|
|
3268
|
-
const migrateModule = require('./migrate');
|
|
3269
|
-
let outcome;
|
|
3270
|
-
try {
|
|
3271
|
-
outcome = await migrateModule.autoMigrateBeadsIfPresent(projectRoot, opts);
|
|
3272
|
-
} catch (err) {
|
|
3273
|
-
addSetupNote(`Beads → Kernel auto-migration failed: ${err.message}`);
|
|
3274
|
-
return { migrated: false };
|
|
3275
|
-
}
|
|
3276
|
-
|
|
3277
|
-
if (!outcome.migrated) {
|
|
3278
|
-
if (outcome.result && outcome.result.success === false) {
|
|
3279
|
-
addSetupNote(`Beads → Kernel auto-migration skipped: ${outcome.result.error}`);
|
|
3280
|
-
}
|
|
3281
|
-
return outcome;
|
|
3282
|
-
}
|
|
3283
|
-
|
|
3284
|
-
const { imported, gaps } = outcome.result;
|
|
3285
|
-
const inserted = imported.issues.inserted;
|
|
3286
|
-
const skipped = imported.issues.skipped;
|
|
3287
|
-
if (inserted > 0) {
|
|
3288
|
-
let line = ` ✓ Migrated ${inserted} issue(s) from Beads to the Kernel`;
|
|
3289
|
-
if (gaps && gaps.count > 0) {
|
|
3290
|
-
line += ` (${gaps.count} field gap(s): ${gaps.items.map(g => g.field).join(', ')})`;
|
|
3291
|
-
}
|
|
3292
|
-
console.log(line);
|
|
3293
|
-
} else {
|
|
3294
|
-
console.log(` ✓ Beads store already present in the Kernel (${skipped} issue(s))`);
|
|
3295
|
-
}
|
|
3296
|
-
return outcome;
|
|
3297
|
-
}
|
|
3298
|
-
|
|
3299
3366
|
// Install git hooks for a target project root without a full setup run.
|
|
3300
3367
|
// Reuses the same lefthook install path setup performs so `forge init` can
|
|
3301
3368
|
// reach a hook-active state (closing the init → HOOKS_NOT_ACTIVE catch-22).
|
|
@@ -3363,9 +3430,6 @@ async function quickSetup(selectedAgents, skipExternal) {
|
|
|
3363
3430
|
// Auto-setup project tools (Kernel issue store, Skills)
|
|
3364
3431
|
await autoSetupToolsInQuickMode();
|
|
3365
3432
|
|
|
3366
|
-
// Auto-import an existing Beads store into the Kernel (idempotent, CLI-free)
|
|
3367
|
-
await autoMigrateBeadsToKernel();
|
|
3368
|
-
|
|
3369
3433
|
// Setup Claude first if selected, then setup remaining agents
|
|
3370
3434
|
if (selectedAgents.includes('claude')) {
|
|
3371
3435
|
await setupAgent('claude');
|
|
@@ -3905,9 +3969,6 @@ async function executeSetup(config) {
|
|
|
3905
3969
|
console.log('');
|
|
3906
3970
|
installGitHooks({ loud: true });
|
|
3907
3971
|
|
|
3908
|
-
// Auto-import an existing Beads store into the Kernel (idempotent, CLI-free)
|
|
3909
|
-
await autoMigrateBeadsToKernel();
|
|
3910
|
-
|
|
3911
3972
|
// External services (unless skipped)
|
|
3912
3973
|
await handleExternalServices(skipExternal, agents);
|
|
3913
3974
|
|
|
@@ -4319,6 +4380,9 @@ module.exports = {
|
|
|
4319
4380
|
},
|
|
4320
4381
|
|
|
4321
4382
|
// Expose internals for testing and cross-command use
|
|
4383
|
+
describeHookEnforcement,
|
|
4384
|
+
resolveHookEnforcementState,
|
|
4385
|
+
buildHookVerdict,
|
|
4322
4386
|
checkPrerequisites,
|
|
4323
4387
|
setupCoreDocs,
|
|
4324
4388
|
displaySetupSummary,
|
|
@@ -4340,7 +4404,6 @@ module.exports = {
|
|
|
4340
4404
|
installViaBunx,
|
|
4341
4405
|
autoInstallLefthook,
|
|
4342
4406
|
autoSetupToolsInQuickMode,
|
|
4343
|
-
autoMigrateBeadsToKernel,
|
|
4344
4407
|
ensureGitHooksInstalled,
|
|
4345
4408
|
forgeShouldWriteLefthookConfig,
|
|
4346
4409
|
FORGE_USER_LEFTHOOK_YML,
|