@chrono-meta/fh-gate 1.4.51 → 1.4.53
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/.claude/registry/README.md +26 -0
- package/.claude/registry/agent_cards.json +73 -0
- package/.claude-plugin/marketplace.json +25 -0
- package/AGENTS.md +2 -2
- package/CATALOG.md +6 -0
- package/CHEATSHEET.md +1 -0
- package/CLAUDE.md +21 -11
- package/README.md +12 -1
- package/bin/fh-codex-doctor.js +419 -0
- package/docs/codex-compat.md +18 -4
- package/knowledge/shared/harness-core/capability_escalation_consent.md +7 -0
- package/knowledge/shared/harness-core/claude_md_gate_details.md +15 -8
- package/knowledge/shared/harness-core/deep_research_capability_ladder.md +1 -1
- package/knowledge/shared/harness-core/fh_detail_protocols.md +4 -0
- package/knowledge/shared/harness-core/loop_engineering.md +80 -0
- package/knowledge/shared/harness-core/multi_model_sidecar_strategy.md +5 -1
- package/knowledge/shared/harness-core/self_evolution_routine.md +17 -12
- package/knowledge/shared/harness-core/sonnet_floor_doctrine.md +132 -0
- package/package.json +11 -3
- package/plugins/fh-commons/.claude-plugin/plugin.json +23 -0
- package/plugins/fh-commons/skills/deliberation/SKILL.md +1 -1
- package/plugins/fh-meta/.claude-plugin/plugin.json +36 -0
- package/plugins/fh-meta/skills/agent-composer/SKILL.md +1 -1
- package/plugins/fh-meta/skills/apex-review/SKILL.md +1 -1
- package/plugins/fh-meta/skills/auto-decorrelation/SKILL.md +1 -1
- package/plugins/fh-meta/skills/context-doctor/SKILL.md +1 -1
- package/plugins/fh-meta/skills/harvest-loop/SKILL.md +1 -1
- package/plugins/fh-meta/skills/install-wizard/SKILL.md +1 -1
- package/plugins/fh-meta/skills/meta-prompt-builder/SKILL.md +1 -1
- package/plugins/fh-meta/skills/sim-conductor/SKILL.md +1 -1
- package/plugins/fh-meta/skills/steel-quench/SKILL.md +1 -1
- package/plugins/fh-meta/skills/verify-bidirectional/SKILL.md +1 -1
- package/scripts/count_check.sh +95 -0
- package/scripts/selfcheck.sh +27 -20
- package/templates/CLAUDE.md +87 -0
- package/templates/local_fh_context.md +18 -0
|
@@ -0,0 +1,419 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
const fs = require('fs');
|
|
5
|
+
const path = require('path');
|
|
6
|
+
|
|
7
|
+
function usage() {
|
|
8
|
+
process.stdout.write(`Usage:
|
|
9
|
+
fh-codex-doctor [--root <path>] [--json] [--strict]
|
|
10
|
+
|
|
11
|
+
Scans FH skills and agents for Codex runtime compatibility drift.
|
|
12
|
+
|
|
13
|
+
Options:
|
|
14
|
+
--root <path> Repository/package root. Defaults to cwd when it looks like FH,
|
|
15
|
+
otherwise this package root.
|
|
16
|
+
--json Emit machine-readable JSON.
|
|
17
|
+
--strict Exit 1 when high-severity drift is found.
|
|
18
|
+
--help Show this help.
|
|
19
|
+
`);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function parseArgs(argv) {
|
|
23
|
+
const out = { root: defaultRoot(), json: false, strict: false };
|
|
24
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
25
|
+
const arg = argv[i];
|
|
26
|
+
if (arg === '--help' || arg === '-h') {
|
|
27
|
+
out.help = true;
|
|
28
|
+
} else if (arg === '--json') {
|
|
29
|
+
out.json = true;
|
|
30
|
+
} else if (arg === '--strict') {
|
|
31
|
+
out.strict = true;
|
|
32
|
+
} else if (arg === '--root') {
|
|
33
|
+
i += 1;
|
|
34
|
+
if (!argv[i]) throw new Error('--root requires a path');
|
|
35
|
+
out.root = path.resolve(argv[i]);
|
|
36
|
+
} else {
|
|
37
|
+
throw new Error(`unknown argument: ${arg}`);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
return out;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function looksLikeFHRoot(dir) {
|
|
44
|
+
return exists(path.join(dir, 'package.json')) &&
|
|
45
|
+
exists(path.join(dir, 'plugins')) &&
|
|
46
|
+
exists(path.join(dir, 'AGENTS.md'));
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function defaultRoot() {
|
|
50
|
+
const cwd = process.cwd();
|
|
51
|
+
if (looksLikeFHRoot(cwd)) return cwd;
|
|
52
|
+
return path.resolve(__dirname, '..');
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function readText(file) {
|
|
56
|
+
return fs.readFileSync(file, 'utf8');
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function maybeReadText(file) {
|
|
60
|
+
try {
|
|
61
|
+
return readText(file);
|
|
62
|
+
} catch (_err) {
|
|
63
|
+
return '';
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function exists(file) {
|
|
68
|
+
try {
|
|
69
|
+
fs.accessSync(file, fs.constants.F_OK);
|
|
70
|
+
return true;
|
|
71
|
+
} catch (_err) {
|
|
72
|
+
return false;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function walk(dir, predicate) {
|
|
77
|
+
const results = [];
|
|
78
|
+
if (!exists(dir)) return results;
|
|
79
|
+
const entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
80
|
+
for (const entry of entries) {
|
|
81
|
+
const p = path.join(dir, entry.name);
|
|
82
|
+
if (entry.isDirectory()) {
|
|
83
|
+
results.push(...walk(p, predicate));
|
|
84
|
+
} else if (!predicate || predicate(p)) {
|
|
85
|
+
results.push(p);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
return results;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function rel(root, file) {
|
|
92
|
+
return path.relative(root, file).split(path.sep).join('/');
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function parseFrontmatter(text) {
|
|
96
|
+
if (!text.startsWith('---\n')) return {};
|
|
97
|
+
const end = text.indexOf('\n---', 4);
|
|
98
|
+
if (end === -1) return {};
|
|
99
|
+
const block = text.slice(4, end).split('\n');
|
|
100
|
+
const out = {};
|
|
101
|
+
for (const line of block) {
|
|
102
|
+
const m = line.match(/^([A-Za-z0-9_-]+):\s*(.*)$/);
|
|
103
|
+
if (!m) continue;
|
|
104
|
+
out[m[1]] = m[2].replace(/^["']|["']$/g, '');
|
|
105
|
+
}
|
|
106
|
+
return out;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function extractBacktickNames(line) {
|
|
110
|
+
const names = [];
|
|
111
|
+
const re = /`([^`]+)`/g;
|
|
112
|
+
let m;
|
|
113
|
+
while ((m = re.exec(line)) !== null) {
|
|
114
|
+
const value = m[1].trim();
|
|
115
|
+
if (/^[a-z0-9][a-z0-9-]*$/.test(value)) names.push(value);
|
|
116
|
+
}
|
|
117
|
+
return names;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function documentedTiers(root) {
|
|
121
|
+
const sources = [
|
|
122
|
+
path.join(root, 'AGENTS.md'),
|
|
123
|
+
];
|
|
124
|
+
const tiers = new Map();
|
|
125
|
+
const evidence = [];
|
|
126
|
+
for (const source of sources) {
|
|
127
|
+
const text = maybeReadText(source);
|
|
128
|
+
if (!text) continue;
|
|
129
|
+
const lines = text.split('\n');
|
|
130
|
+
lines.forEach((line, index) => {
|
|
131
|
+
const tierMatch = line.match(/\|\s*\*\*(M[123])\b/);
|
|
132
|
+
if (!tierMatch) return;
|
|
133
|
+
const tier = tierMatch[1];
|
|
134
|
+
for (const name of extractBacktickNames(line)) {
|
|
135
|
+
tiers.set(name, { tier, source: rel(root, source), line: index + 1 });
|
|
136
|
+
evidence.push({ name, tier, source: rel(root, source), line: index + 1 });
|
|
137
|
+
}
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
return { tiers, evidence };
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function compatDocTierMentions(root, skillNames) {
|
|
144
|
+
const source = path.join(root, 'docs', 'codex-compat.md');
|
|
145
|
+
const text = maybeReadText(source);
|
|
146
|
+
const mentions = new Map();
|
|
147
|
+
if (!text) return mentions;
|
|
148
|
+
const lines = text.split('\n');
|
|
149
|
+
lines.forEach((line, index) => {
|
|
150
|
+
const tierMatch = line.match(/\b(M[123])\b/);
|
|
151
|
+
if (!tierMatch) return;
|
|
152
|
+
const tier = tierMatch[1];
|
|
153
|
+
for (const name of extractBacktickNames(line)) {
|
|
154
|
+
if (!skillNames.has(name)) continue;
|
|
155
|
+
if (!mentions.has(name)) mentions.set(name, []);
|
|
156
|
+
mentions.get(name).push({ tier, source: rel(root, source), line: index + 1 });
|
|
157
|
+
}
|
|
158
|
+
});
|
|
159
|
+
return mentions;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const PRIMITIVES = [
|
|
163
|
+
{
|
|
164
|
+
id: 'agent-dispatch',
|
|
165
|
+
severity: 'adapter',
|
|
166
|
+
regexes: [/Agent\s*\(/, /\bsubagent_type\b/, /\bAgent View\b/, /\bAgent\b tool\b/, /\bAgent invocation instruction\b/, /\bparallel-Agent dispatch\b/],
|
|
167
|
+
},
|
|
168
|
+
{
|
|
169
|
+
id: 'slash-command',
|
|
170
|
+
severity: 'adapter',
|
|
171
|
+
regexes: [/(^|[\s`])\/[a-z][a-z0-9-]+(?=($|[\s`),.;:}]))/m],
|
|
172
|
+
},
|
|
173
|
+
{
|
|
174
|
+
id: 'hook',
|
|
175
|
+
severity: 'claude-native',
|
|
176
|
+
regexes: [/\bStop hook\b/i, /\bPostToolUse\b/, /\bSessionStart\b/],
|
|
177
|
+
},
|
|
178
|
+
{
|
|
179
|
+
id: 'model-command',
|
|
180
|
+
severity: 'claude-native',
|
|
181
|
+
regexes: [/(^|[\s`])\/model\b/m],
|
|
182
|
+
},
|
|
183
|
+
];
|
|
184
|
+
|
|
185
|
+
function scanPrimitives(text) {
|
|
186
|
+
const hits = [];
|
|
187
|
+
for (const primitive of PRIMITIVES) {
|
|
188
|
+
for (const regex of primitive.regexes) {
|
|
189
|
+
const m = regex.exec(text);
|
|
190
|
+
if (!m) continue;
|
|
191
|
+
hits.push({
|
|
192
|
+
id: primitive.id,
|
|
193
|
+
severity: primitive.severity,
|
|
194
|
+
line: text.slice(0, m.index).split('\n').length,
|
|
195
|
+
match: m[0].trim(),
|
|
196
|
+
});
|
|
197
|
+
break;
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
return hits;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function classify(docTier, primitives) {
|
|
204
|
+
const hasClaudeNative = primitives.some((p) => p.severity === 'claude-native');
|
|
205
|
+
const hasAgentDispatch = primitives.some((p) => p.id === 'agent-dispatch');
|
|
206
|
+
const hasAdapter = primitives.some((p) => p.severity === 'adapter');
|
|
207
|
+
if (docTier === 'M1' && (hasClaudeNative || hasAgentDispatch)) return 'tier-drift';
|
|
208
|
+
if (docTier === 'M1') return 'codex-native';
|
|
209
|
+
if (docTier === 'M2') return 'adapter-required';
|
|
210
|
+
if (docTier === 'M3') return 'claude-native';
|
|
211
|
+
if (hasClaudeNative) return 'claude-native-unclassified';
|
|
212
|
+
if (hasAdapter) return 'adapter-required-unclassified';
|
|
213
|
+
return 'codex-native-candidate';
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function collectSkills(root) {
|
|
217
|
+
const skillsRoot = path.join(root, 'plugins');
|
|
218
|
+
const files = walk(skillsRoot, (p) => path.basename(p) === 'SKILL.md');
|
|
219
|
+
return files.sort().map((file) => {
|
|
220
|
+
const text = readText(file);
|
|
221
|
+
const fm = parseFrontmatter(text);
|
|
222
|
+
const skillName = fm.name || path.basename(path.dirname(file));
|
|
223
|
+
return {
|
|
224
|
+
type: 'skill',
|
|
225
|
+
name: skillName,
|
|
226
|
+
path: rel(root, file),
|
|
227
|
+
frontmatter: fm,
|
|
228
|
+
primitives: scanPrimitives(text),
|
|
229
|
+
};
|
|
230
|
+
});
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function collectAgents(root) {
|
|
234
|
+
const agents = [];
|
|
235
|
+
for (const plugin of ['fh-meta', 'fh-commons']) {
|
|
236
|
+
const dir = path.join(root, 'plugins', plugin, 'agents');
|
|
237
|
+
for (const file of walk(dir, (p) => p.endsWith('.md')).sort()) {
|
|
238
|
+
const text = readText(file);
|
|
239
|
+
agents.push({
|
|
240
|
+
type: 'agent',
|
|
241
|
+
name: path.basename(file, '.md'),
|
|
242
|
+
path: rel(root, file),
|
|
243
|
+
plugin,
|
|
244
|
+
primitives: scanPrimitives(text),
|
|
245
|
+
});
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
return agents;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function loadAgentCards(root) {
|
|
252
|
+
const file = path.join(root, '.claude', 'registry', 'agent_cards.json');
|
|
253
|
+
if (!exists(file)) return { present: false, count: 0 };
|
|
254
|
+
try {
|
|
255
|
+
const parsed = JSON.parse(readText(file));
|
|
256
|
+
const count = Array.isArray(parsed)
|
|
257
|
+
? parsed.length
|
|
258
|
+
: (Array.isArray(parsed.agents) ? parsed.agents.length : (parsed.agent_count || Object.keys(parsed).length));
|
|
259
|
+
return { present: true, path: rel(root, file), count };
|
|
260
|
+
} catch (err) {
|
|
261
|
+
return { present: true, path: rel(root, file), error: err.message, count: 0 };
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
function buildReport(root) {
|
|
266
|
+
const docs = documentedTiers(root);
|
|
267
|
+
const skills = collectSkills(root).map((skill) => {
|
|
268
|
+
const doc = docs.tiers.get(skill.name);
|
|
269
|
+
return {
|
|
270
|
+
...skill,
|
|
271
|
+
documentedTier: doc ? doc.tier : null,
|
|
272
|
+
tierEvidence: doc || null,
|
|
273
|
+
codexMode: classify(doc && doc.tier, skill.primitives),
|
|
274
|
+
};
|
|
275
|
+
});
|
|
276
|
+
const agents = collectAgents(root);
|
|
277
|
+
const skillNames = new Set(skills.map((s) => s.name));
|
|
278
|
+
const compatMentions = compatDocTierMentions(root, skillNames);
|
|
279
|
+
const findings = [];
|
|
280
|
+
|
|
281
|
+
for (const item of skills) {
|
|
282
|
+
if (item.documentedTier === 'M1') {
|
|
283
|
+
const bad = item.primitives.filter((p) => p.severity === 'claude-native' || p.id === 'agent-dispatch');
|
|
284
|
+
if (bad.length > 0) {
|
|
285
|
+
findings.push({
|
|
286
|
+
severity: 'HIGH',
|
|
287
|
+
code: 'M1_HAS_CLAUDE_NATIVE_PRIMITIVE',
|
|
288
|
+
path: item.path,
|
|
289
|
+
message: `${item.name} is documented M1 but contains ${bad.map((p) => p.id).join(', ')}`,
|
|
290
|
+
});
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
for (const entry of docs.evidence) {
|
|
296
|
+
if (!skillNames.has(entry.name)) {
|
|
297
|
+
findings.push({
|
|
298
|
+
severity: 'WARN',
|
|
299
|
+
code: 'DOC_TIER_REFERENCES_MISSING_SKILL',
|
|
300
|
+
path: `${entry.source}:${entry.line}`,
|
|
301
|
+
message: `${entry.name} is listed as ${entry.tier} but no matching SKILL.md was found`,
|
|
302
|
+
});
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
for (const item of skills) {
|
|
307
|
+
const mentions = compatMentions.get(item.name) || [];
|
|
308
|
+
for (const mention of mentions) {
|
|
309
|
+
if (item.documentedTier && mention.tier !== item.documentedTier) {
|
|
310
|
+
findings.push({
|
|
311
|
+
severity: 'HIGH',
|
|
312
|
+
code: 'CODEX_COMPAT_TIER_DISAGREES_WITH_AGENTS',
|
|
313
|
+
path: `${mention.source}:${mention.line}`,
|
|
314
|
+
message: `${item.name} is ${item.documentedTier} in AGENTS.md but ${mention.tier} in docs/codex-compat.md`,
|
|
315
|
+
});
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
const counts = {
|
|
321
|
+
skills: skills.length,
|
|
322
|
+
agents: agents.length,
|
|
323
|
+
modes: {},
|
|
324
|
+
tiers: { M1: 0, M2: 0, M3: 0, unclassified: 0 },
|
|
325
|
+
findings: {
|
|
326
|
+
HIGH: findings.filter((f) => f.severity === 'HIGH').length,
|
|
327
|
+
WARN: findings.filter((f) => f.severity === 'WARN').length,
|
|
328
|
+
},
|
|
329
|
+
};
|
|
330
|
+
for (const skill of skills) {
|
|
331
|
+
counts.modes[skill.codexMode] = (counts.modes[skill.codexMode] || 0) + 1;
|
|
332
|
+
counts.tiers[skill.documentedTier || 'unclassified'] += 1;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
return {
|
|
336
|
+
status: findings.some((f) => f.severity === 'HIGH') ? 'DRIFT' : 'OK',
|
|
337
|
+
root,
|
|
338
|
+
counts,
|
|
339
|
+
agentCards: loadAgentCards(root),
|
|
340
|
+
findings,
|
|
341
|
+
skills,
|
|
342
|
+
agents: agents.map((agent) => ({
|
|
343
|
+
name: agent.name,
|
|
344
|
+
path: agent.path,
|
|
345
|
+
plugin: agent.plugin,
|
|
346
|
+
codexMode: 'adapter-runnable',
|
|
347
|
+
note: 'Agents are runnable via fh-run --agent or a runtime-specific dispatch adapter; auto-dispatch is host-specific.',
|
|
348
|
+
})),
|
|
349
|
+
};
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
function printText(report) {
|
|
353
|
+
const lines = [];
|
|
354
|
+
lines.push('FH Codex Doctor');
|
|
355
|
+
lines.push(`Status: ${report.status}`);
|
|
356
|
+
lines.push(`Root: ${report.root}`);
|
|
357
|
+
lines.push('');
|
|
358
|
+
lines.push('Summary');
|
|
359
|
+
lines.push(`- Skills scanned: ${report.counts.skills}`);
|
|
360
|
+
lines.push(`- Agents scanned: ${report.counts.agents}`);
|
|
361
|
+
lines.push(`- Documented tiers: M1=${report.counts.tiers.M1} M2=${report.counts.tiers.M2} M3=${report.counts.tiers.M3} unclassified=${report.counts.tiers.unclassified}`);
|
|
362
|
+
lines.push(`- Codex modes: ${Object.keys(report.counts.modes).sort().map((k) => `${k}=${report.counts.modes[k]}`).join(' ')}`);
|
|
363
|
+
if (report.agentCards.present) {
|
|
364
|
+
lines.push(`- Agent cards: ${report.agentCards.count}${report.agentCards.error ? ` (parse error: ${report.agentCards.error})` : ''}`);
|
|
365
|
+
} else {
|
|
366
|
+
lines.push('- Agent cards: missing');
|
|
367
|
+
}
|
|
368
|
+
lines.push('');
|
|
369
|
+
lines.push('Findings');
|
|
370
|
+
if (report.findings.length === 0) {
|
|
371
|
+
lines.push('- none');
|
|
372
|
+
} else {
|
|
373
|
+
for (const finding of report.findings) {
|
|
374
|
+
lines.push(`- ${finding.severity} ${finding.code} ${finding.path} :: ${finding.message}`);
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
lines.push('');
|
|
378
|
+
lines.push('Codex Runtime Contract');
|
|
379
|
+
lines.push('- codex-native: run directly with fh-run/codex exec.');
|
|
380
|
+
lines.push('- adapter-required: core method can run, but Agent/slash steps need adapter substitution.');
|
|
381
|
+
lines.push('- claude-native: do not auto-pass; require Claude Code host or explicit dedicated adapter.');
|
|
382
|
+
lines.push('- unclassified modes are drift signals for future manifest backfill.');
|
|
383
|
+
process.stdout.write(`${lines.join('\n')}\n`);
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
function main() {
|
|
387
|
+
let args;
|
|
388
|
+
try {
|
|
389
|
+
args = parseArgs(process.argv.slice(2));
|
|
390
|
+
} catch (err) {
|
|
391
|
+
process.stderr.write(`ERROR: ${err.message}\n`);
|
|
392
|
+
usage();
|
|
393
|
+
process.exit(11);
|
|
394
|
+
}
|
|
395
|
+
if (args.help) {
|
|
396
|
+
usage();
|
|
397
|
+
return;
|
|
398
|
+
}
|
|
399
|
+
const root = args.root;
|
|
400
|
+
if (!exists(path.join(root, 'package.json'))) {
|
|
401
|
+
process.stderr.write(`ERROR: root does not look like a package/repo root: ${root}\n`);
|
|
402
|
+
process.exit(11);
|
|
403
|
+
}
|
|
404
|
+
if (!looksLikeFHRoot(root)) {
|
|
405
|
+
process.stderr.write(`ERROR: root is missing required FH surfaces (AGENTS.md and plugins/): ${root}\n`);
|
|
406
|
+
process.exit(11);
|
|
407
|
+
}
|
|
408
|
+
const report = buildReport(root);
|
|
409
|
+
if (args.json) {
|
|
410
|
+
process.stdout.write(`${JSON.stringify(report, null, 2)}\n`);
|
|
411
|
+
} else {
|
|
412
|
+
printText(report);
|
|
413
|
+
}
|
|
414
|
+
if (args.strict && report.counts.findings.HIGH > 0) {
|
|
415
|
+
process.exit(1);
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
main();
|
package/docs/codex-compat.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
> Status: **beta**. This document is beta-removal condition #2 (see `AGENTS.md` → Codex Compatibility → Beta removal conditions). It lists what works, what breaks, and what to expect when applying forge-harness (FH) methodology through OpenAI Codex (`codex exec`) instead of Claude Code.
|
|
4
4
|
|
|
5
|
-
FH is a 2-layer system: a **methodology layer** (`tracks/`, `knowledge/`, `SKILL.md` docs) that is model-agnostic, and an **automation layer** (Claude Code hooks, `.claude/agents/`, `/model`, settings.json) that is Claude-native. Codex users run the methodology layer by reading `SKILL.md` files directly; automation steps either run through runtime adapters (`fh-gate`, `fh-run`) or require manual substitution.
|
|
5
|
+
FH is a 2-layer system: a **methodology layer** (`tracks/`, `knowledge/`, `SKILL.md` docs) that is model-agnostic, and an **automation layer** (Claude Code hooks, plugin-channel agents under `plugins/*/agents/`, field-project overrides under `.claude/agents/`, `/model`, settings.json) that is Claude-native. Codex users run the methodology layer by reading `SKILL.md` files directly; automation steps either run through runtime adapters (`fh-gate`, `fh-run`) or require manual substitution.
|
|
6
6
|
|
|
7
7
|
## Validated invocation pattern
|
|
8
8
|
|
|
@@ -65,6 +65,20 @@ Resolution order:
|
|
|
65
65
|
| `--agent plugin:name` | `plugins/plugin/agents/name.md` first |
|
|
66
66
|
| `--unit path` | explicit file path |
|
|
67
67
|
|
|
68
|
+
### `fh-codex-doctor`
|
|
69
|
+
|
|
70
|
+
`fh-codex-doctor` is the drift detector for the thin Codex adapter boundary. It reads the canonical
|
|
71
|
+
FH skill/agent files plus the documented M1/M2/M3 tier table, then reports which surfaces are
|
|
72
|
+
Codex-native, adapter-required, Claude-native, or unclassified:
|
|
73
|
+
|
|
74
|
+
```bash
|
|
75
|
+
npx --package @chrono-meta/fh-gate fh-codex-doctor --strict
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
Use it before promoting a new Codex automation path. Unknown Claude-native primitives fail closed as
|
|
79
|
+
"manual adaptation required" instead of being treated as compatible by default. When run from an FH
|
|
80
|
+
checkout it scans the current working tree; outside a checkout it scans the installed package.
|
|
81
|
+
|
|
68
82
|
### `fh-goal`
|
|
69
83
|
|
|
70
84
|
Codex has native goal/session features. Use those directly when they fit. `fh-goal` is not a replacement for Codex goal; it is a non-interactive wrapper for "run backend task, then run FH governance on changed files":
|
|
@@ -92,7 +106,7 @@ Both ran end-to-end with no Claude-native dependency. The M1 tier claim holds fo
|
|
|
92
106
|
When `codex exec` runs **inside this repo**, FH's Claude-native git/Stop/PostToolUse hooks attempt to fire and emit `hook: Stop Failed` / `hook: PostToolUse Failed` lines interleaved with output. These are **harmless to the skill result** — the skill's verdict is produced correctly — but they are visible noise. Running from a directory **without** FH's `.claude/settings.json` (the normal Codex-user case) avoids them entirely. Filter with `grep -vE "^hook:"` if needed.
|
|
93
107
|
|
|
94
108
|
### 2. M2 skills need manual agent substitution
|
|
95
|
-
M2 skills (`steel-quench`, `harness-doctor`, `context-doctor`, `sim-conductor`, `harvest-loop`) have a core workflow that runs under Codex, but any step that dispatches `Agent(subagent_type=...)` or a slash command must be replaced by `fh-run` or a direct `codex exec` call reading the sub-agent's `SKILL.md`/agent `.md` — same workflow, different runtime (the "M2 adaptation pattern" in `AGENTS.md`). Example: `steel-quench` Waves 1–3 run; the `quench-challenger` agent step becomes `fh-run --agent fh-commons:quench-challenger`.
|
|
109
|
+
M2 skills (`deliberation`, `steel-quench`, `harness-doctor`, `context-doctor`, `sim-conductor`, `harvest-loop`) have a core workflow that runs under Codex, but any step that dispatches `Agent(subagent_type=...)` or a slash command must be replaced by `fh-run` or a direct `codex exec` call reading the sub-agent's `SKILL.md`/agent `.md` — same workflow, different runtime (the "M2 adaptation pattern" in `AGENTS.md`). Example: `steel-quench` Waves 1–3 run; the `quench-challenger` agent step becomes `fh-run --agent fh-commons:quench-challenger`.
|
|
96
110
|
|
|
97
111
|
### 3. M3 skills do not run automatically under Codex
|
|
98
112
|
M3 skills (`goal-quench` Phase-3 Stop hook, `hub-cc-pr-reviewer` CC session context, `install-wizard` settings.json write) require Claude-Code-native runtime and are **methodology reference only** under Codex unless a dedicated adapter exists. Use Codex's native goal/session features for goal control, and use `fh-gate` after completion for FH quality gating.
|
|
@@ -107,8 +121,8 @@ The sibling pattern for Gemini is `gemini -p "$(cat <skill+artifact>)"`. Outside
|
|
|
107
121
|
|
|
108
122
|
| Tier | Under Codex | Action |
|
|
109
123
|
|---|---|---|
|
|
110
|
-
| **M1** | Runs fully | `cat SKILL.md artifact \| codex exec -m gpt-5.5 -` |
|
|
111
|
-
| **M2** | Core runs; agent/slash steps via adapter | Substitute each dispatch with `fh-run` or a direct `codex exec` on the sub-agent's `.md` |
|
|
124
|
+
| **M1** | Runs fully (`token-budget-gate`, `asset-placement-gate`, `phantom-quench`, `deep-clarify`, `convergence-loop`) | `cat SKILL.md artifact \| codex exec -m gpt-5.5 -` |
|
|
125
|
+
| **M2** | Core runs; agent/slash steps via adapter (`deliberation`, `steel-quench`, `harness-doctor`, `context-doctor`, `sim-conductor`, `harvest-loop`) | Substitute each dispatch with `fh-run` or a direct `codex exec` on the sub-agent's `.md` |
|
|
112
126
|
| **M3** | Does not run automatically | Use native Codex session features where available; otherwise read as methodology reference or use a dedicated adapter |
|
|
113
127
|
|
|
114
128
|
## Beta removal — remaining (external-blocked)
|
|
@@ -123,3 +123,10 @@ to offload exec cost off the paid API entirely. The protocol turns hard-won econ
|
|
|
123
123
|
protocol never flips the model itself.
|
|
124
124
|
- **Sonnet floor is a floor, not a ceiling** — declined-floor-up still escalates *within* Sonnet's
|
|
125
125
|
harness depth (sub-agents, good structure); it does not cap capability, only cost/trust surface.
|
|
126
|
+
|
|
127
|
+
---
|
|
128
|
+
|
|
129
|
+
> **Canonical axiom cross-ref (2026-07-10)**: the "Sonnet floor is first-class, not degraded" stance
|
|
130
|
+
> this protocol operationalizes is now named — `sonnet_floor_doctrine.md` (base ops 100% Sonnet;
|
|
131
|
+
> tier-gated capability = defect; escalation = dispatch, consent-gated **here**). This file remains
|
|
132
|
+
> the consent mechanics home; the doctrine node does not restate them.
|
|
@@ -69,10 +69,13 @@ demand a strict YES/NO + one-line reason, judge whether the rule fired (mechanis
|
|
|
69
69
|
directions — a claim checkable against that skill — re-validating that day's salience-binding fix at a
|
|
70
70
|
sub-Sonnet tier).
|
|
71
71
|
|
|
72
|
-
**FAIL-triage**: a FAIL never blocks alone — the
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
72
|
+
**FAIL-triage**: a FAIL never blocks alone — the orchestrator (whatever tier is driving; the triage
|
|
73
|
+
judgment is *trusted* at opus+ and run-or-ask below, per §Floor governance) triages it as a *real
|
|
74
|
+
salience gap* (fix the rule) vs a *floor-model quirk* (small-model loop/hallucination, per the public
|
|
75
|
+
"Local AI is not Opus" finding + the cheap-oracle ceiling — a small model adds nothing where one grep
|
|
76
|
+
already settles the check). The terminal verdict stays with the **Sonnet-or-higher governor bound to a
|
|
77
|
+
mechanical anchor** (Sonnet sim verdict + the anchor evidence; an opus judge is the *dispatch-recommended*
|
|
78
|
+
strengthener, not a requirement — Sonnet-Floor Doctrine 2026-07-10) — no judge-only path, no
|
|
76
79
|
weak-local-judge regression of the judge-robustness principle (mechanical anchor over judge-only verdict).
|
|
77
80
|
The cross-family-panel upgrade spec lives in the private companion store's `handoff/` design note.
|
|
78
81
|
|
|
@@ -190,11 +193,15 @@ to be modified), check the **session model** (self-identity; if the runtime with
|
|
|
190
193
|
unknown) and surface **one line** — then proceed, never block:
|
|
191
194
|
|
|
192
195
|
- Model known and opus-tier or above → no notice (already optimal).
|
|
193
|
-
- Model known and below opus-tier →
|
|
194
|
-
|
|
195
|
-
|
|
196
|
+
- Model known and below opus-tier → **dispatch-first** (Sonnet-Floor Doctrine 2026-07-10 — the
|
|
197
|
+
primary recommendation keeps the Sonnet substrate and routes depth to dispatch; a session pin is
|
|
198
|
+
the *secondary* option): *"이 작업은 FH 자체개발(Mode D)입니다 — Sonnet 그대로 진행하면서 깊이
|
|
199
|
+
턴(적대검증·설계리뷰)은 사이드카/opus 디스패치로 커버하는 걸 권장합니다(동의 게이트:
|
|
200
|
+
capability_escalation_consent). 세션 전체가 설계-깊이 중심이면 차선으로 `/model opus` 핀도
|
|
201
|
+
가능합니다."*
|
|
196
202
|
- Model unknown (runtime withholds identity) → static fallback: *"FH 자체개발 작업입니다 — 세션
|
|
197
|
-
모델이 opus
|
|
203
|
+
모델이 opus 미만이면 깊이 턴을 디스패치로 커버하세요(권장); 설계-깊이 세션이면 `/model opus`
|
|
204
|
+
핀이 차선입니다."*
|
|
198
205
|
|
|
199
206
|
**Guards**: once per session · advisory only — **never switch the session model** (human override is
|
|
200
207
|
inviolable; a pin is not a cap — tier-floor resolution §Floor governance) · field-project operation
|
|
@@ -23,7 +23,7 @@ research-heavy task can pull it.
|
|
|
23
23
|
| Rung | Capability | When it applies | Tier note |
|
|
24
24
|
|---|---|---|---|
|
|
25
25
|
| **1. Agentic research skill** | An autonomous multi-step researcher **present in the live session skill list** — in Claude Code that is `octo:research` (Claude Octopus, multi-AI synthesis) when installed. A native `/deep-research` was **not registered in CC in this install** (measured 2026-06-14: Skill `deep-research` → "Unknown skill"; the Claude **app** surfaces it highlighted, this CC build does not). Treat it as **app-side / operator-invoked unless a future CC build surfaces it** — re-detect from the live skill list, don't assume CC *cannot* have it (capability is install- and version-dependent — `[[feedback_verify_before_downgrade]]`) | Best when agent-fireable: runs its own search→read→synthesize loop | Self-contained; an external multi-AI path (Octopus → Gemini/Codex) bills **outside** CC's budget |
|
|
26
|
-
| **2. Claude multi-source synthesis** | `WebSearch` + `WebFetch` tools, synthesized in-context | The always-available floor for any Claude session — no extra install | **Tier-sensitive**: synthesis depth tracks the session model. Routine survey = Sonnet default; deep analysis / contested findings = pin
|
|
26
|
+
| **2. Claude multi-source synthesis** | `WebSearch` + `WebFetch` tools, synthesized in-context | The always-available floor for any Claude session — no extra install | **Tier-sensitive**: synthesis depth tracks the session model. Routine survey = Sonnet default; deep analysis / contested findings = dispatch-first (route the deep-read to an opus/sidecar agent, consent-gated; session pin secondary — sonnet_floor_doctrine.md; tier-floor mechanics: `multi_model_sidecar_strategy.md §Tier-floor resolution`) |
|
|
27
27
|
| **3. `frontier-digest`** | The narrow specialization — HN + arxiv trend scan with FH-context synthesis | Use **only** when the research *is* AI/harness trend-scanning, not general topic research | FH-native; has its own WebSearch fallback |
|
|
28
28
|
|
|
29
29
|
**Resolution rule**: detect research-heavy intent → check the live skill list → take rung 1 if a
|
|
@@ -175,6 +175,10 @@ priority: high|medium|low
|
|
|
175
175
|
|
|
176
176
|
**forge-harness is not meant to use more tokens** — standard tier delivers meaningful improvements while minimizing token usage.
|
|
177
177
|
|
|
178
|
+
> Terminology guard: the S/M/L/XL **execution tier is a token-depth budget, NOT a model tier** — it is
|
|
179
|
+
> orthogonal to the Sonnet-floor / model-floor axis (`sonnet_floor_doctrine.md`); an XL run on Sonnet and
|
|
180
|
+
> an S run on Opus are both legal combinations.
|
|
181
|
+
|
|
178
182
|
```yaml
|
|
179
183
|
EXECUTION_TIER: standard # light / standard / full / max
|
|
180
184
|
```
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
# Loop Engineering — the 5-question discipline and FH's loop inventory
|
|
2
|
+
|
|
3
|
+
> Companion node to the CLAUDE.md §Field-Harness Diagnostic **Loop-readiness lens** (its detail
|
|
4
|
+
> home) and to `sonnet_floor_doctrine.md` (PROSE loop legs are exactly where Sonnet-tier misses
|
|
5
|
+
> live — one spine, two lenses). Origin: 황민호 loop-eng 5-question review (2026-07-10, C-tier
|
|
6
|
+
> sister ledger — dedup hit on coverage, the *lens* absorbed) + Loop Engineering sister
|
|
7
|
+
> (`[[project_loop_engineering_sister]]`, Prompt→Context→Harness→Loop layering).
|
|
8
|
+
|
|
9
|
+
## The 5 questions — from diagnostic to design-time discipline
|
|
10
|
+
|
|
11
|
+
A path that *runs* is not a path that *loops*. Before authoring any autonomous path (skill step
|
|
12
|
+
chain, routine, close sequence), answer all five **at design time** — the diagnostic lens then only
|
|
13
|
+
re-checks what authoring already declared:
|
|
14
|
+
|
|
15
|
+
| # | Question | FH's mechanical form |
|
|
16
|
+
|---|---|---|
|
|
17
|
+
| 1 | **Initiate** — what starts it, mechanically or by utterance? | trigger phrases (≥3, gate-checked) · hooks (SessionStart/Stop/pre-commit) · cadence rules |
|
|
18
|
+
| 2 | **Complete** — is there a Done-When? | Done-When with declared check class (mandatory-pass / measured / judged) — already a skill-gate item |
|
|
19
|
+
| 3 | **Validate** — is the check anchored, not judge-only? | mechanical anchor over judge verdict (`[[feedback_judge_robustness_mechanical_anchor]]`); judged conditions name their adversarial pairing |
|
|
20
|
+
| 4 | **Halt** — budget guard, convergence detection, runaway stop? | token-budget-gate · convergence-loop N-round cap · goal-quench thresholds · the 1000-agent backstop |
|
|
21
|
+
| 5 | **Persist** — does state reach the next run? | session card · handoff STATUS stamps · edit-manifest predict-verify · memory |
|
|
22
|
+
|
|
23
|
+
**Design-time rule**: a new autonomous path whose author cannot answer one of the five has found a
|
|
24
|
+
defect *before shipping it* — cheaper than the diagnostic finding it later. The field-asset
|
|
25
|
+
scaffold (auto_project_mapping §6) carries halt + persist stubs so field skills answer #4/#5 by
|
|
26
|
+
construction, the same by-construction pattern as the gate-compliant skeleton.
|
|
27
|
+
|
|
28
|
+
## FH loop inventory — legs, enforcement class, measured gaps
|
|
29
|
+
|
|
30
|
+
Census 2026-07-10, all rows cross-family source-verified (codex gpt-5.5 — xhigh for micro/session/
|
|
31
|
+
weekly, high for quarterly/substrate; the substrate row's original governor self-assessment was
|
|
32
|
+
4/5 REFUTED by the external pass — the census discipline earning its keep). MECH = hook /
|
|
33
|
+
script / exit-code (tier-independent); PROSE = salience-dependent (Sonnet-floor risk surface).
|
|
34
|
+
|
|
35
|
+
| Loop | initiate | complete | validate | halt | persist |
|
|
36
|
+
|---|---|---|---|---|---|
|
|
37
|
+
| **Micro** — goal-quench | MECH (`.active` state) | mixed (Done-When explicit; `/goal` invocation manual) | MECH (`.pending` + pipeline-conductor gate) | **PROSE** (mid-run thresholds instructional) | MECH (calibration record) |
|
|
38
|
+
| **Session** — close chain ①–⑥ | PROSE (closing-phrase trigger) | **mixed** (card-last + step coverage now verified by `scripts/session_close_check.sh` — exit 1 on violation; the *performing* stays with the session, the *catching* is MECH, 2026-07-10) | mixed (git/PR inputs mech, synthesis remembered) | PROSE (no budget/stop guard) | **mixed** (companion-store sync script + SessionStart STATUS map + close-check ⑤ invariant, 2026-07-10) |
|
|
39
|
+
| **Weekly** — harvest-loop / audit cycle | PROSE (proposal at session start) | PROSE (self-reported Done-When) | mixed (`below_floor_scan.sh` exit code; rest hand-gathered) | mixed (critic retry cap 1; no global budget) | PROSE (audit file by hand) |
|
|
40
|
+
| **Quarterly** — maturity roadmap | PROSE (~90d cadence, no auto-detection) | PROSE (phase gates, checked manually) | PROSE (basis-path obligations, no anchor bundle) | **PROSE** (transition deferral / Phase-regression guards exist — hub_maturity_roadmap §6.1 — prose, NOT n/a) | PROSE (roadmap doc, no canonical state file) |
|
|
41
|
+
| **Substrate** — self-adaptation mission | **mixed→MECH improving** (routine schedules + context-entry proposals; the "substrate-version jump" detector now EXISTS — `scripts/substrate_jump_detector.sh`, SessionStart-wired, silent-unless-jump; was a phantom until 2026-07-10) | PROSE (routine terminal states are prompt-following) | mixed (weekly change runs the 4-axis gate — marker form MECH at commit; removal approval prose/HITL) | PROSE (one-proposal-per-week · deferred-draft-PR fallback · stop-after-PR — guards exist, all prose, NOT n/a) | mixed (GitHub issue comments + draft PRs are the durable spine, plus gate markers/edit-manifest/fh_signals — not "memory") |
|
|
42
|
+
|
|
43
|
+
**Reading the map**: the 4-axis auto-gate is FH's only all-MECH loop (initiate=hook detect,
|
|
44
|
+
complete=all-axes-or-block, halt=missing-marker-fails, persist=marker+manifest) — and it is also
|
|
45
|
+
FH's most trusted loop. That correlation is the doctrine: **trust tracks mechanization, not model
|
|
46
|
+
tier.** The PROSE-densest loops (session close, weekly) are where the measured misses actually
|
|
47
|
+
occurred (card staleness 2026-07-10; audit cadence slips).
|
|
48
|
+
|
|
49
|
+
## Case study — persist-leg mechanization (2026-07-10)
|
|
50
|
+
|
|
51
|
+
Company sessions push results to the companion store but never run the local close chain, so the
|
|
52
|
+
card's ⑤ update was the only reconcile point — prose, and mtime-blind: a status stamp landing
|
|
53
|
+
*before* a card rewrite became permanently invisible to the "newer than card" list. Fix: the
|
|
54
|
+
SessionStart hook now emits an **mtime-independent STATUS map** (all DONE/SUPERSEDED/RESOLVED
|
|
55
|
+
stamps, every session) with an explicit cross-check imperative. One measured miss → one mechanized
|
|
56
|
+
leg — the standing pattern (`sonnet_floor_doctrine.md §Why`).
|
|
57
|
+
|
|
58
|
+
## Hardening backlog — evidence-threshold, NOT built speculatively
|
|
59
|
+
|
|
60
|
+
The census surfaces candidate hardenings (close-chain checklist script, weekly-audit scaffold
|
|
61
|
+
script, harvest-loop evidence bundle, goal-quench checkpoint files). Per the build discipline
|
|
62
|
+
(`[[feedback_evidence_threshold_build_discipline]]`), each is built **only when its miss is
|
|
63
|
+
measured** (a real slip attributable to that PROSE leg), mirroring how the SessionStart hook and
|
|
64
|
+
STATUS map each shipped on a production miss, not a guess. Recording the map here *is* the
|
|
65
|
+
instrument: the next slip finds its leg pre-diagnosed.
|
|
66
|
+
|
|
67
|
+
| Backlog item | Fires when (measured trigger) |
|
|
68
|
+
|---|---|
|
|
69
|
+
| ~~Close-chain ordered-checklist script~~ | **BUILT 2026-07-10** (`scripts/session_close_check.sh`) — operator strengthen-instruction; the miss class (card staleness) was already measured, only the build trigger was overridden (recorded, not silent) |
|
|
70
|
+
| Weekly-audit scaffold + data-gather script | a weekly audit missed or hand-gathered wrong window data |
|
|
71
|
+
| harvest-loop Step 0-b/0-c evidence check | a harvest run misses completed items despite `fh_completed_*` existing |
|
|
72
|
+
| goal-quench mid-run checkpoint files (70/85/95%) | a /goal run blows through a threshold unnoticed |
|
|
73
|
+
| ~~Substrate-jump detector~~ | **BUILT 2026-07-10** (`scripts/substrate_jump_detector.sh`, SessionStart-wired) — same operator instruction; structure-enforcing class (out-of-context drift), permanent per the durable-mechanization criterion |
|
|
74
|
+
| Quarterly maturity checker (`quarterly_maturity_check.sh` — criterion status + §6.1 BLOCKED emit) | a quarterly re-diagnosis is missed >90d or a phase transition skips the simplification checklist |
|
|
75
|
+
|
|
76
|
+
## Done When (for a new/changed autonomous path)
|
|
77
|
+
|
|
78
|
+
- All 5 questions answered at design time, each leg labeled MECH or PROSE *(mandatory-pass)*.
|
|
79
|
+
- Any judged validate-leg names its adversarial pairing *(mandatory-pass — inherits the skill gate)*.
|
|
80
|
+
- PROSE legs on load-bearing paths carry a Sonnet blind-sim verdict *(measured — doctrine §ladder step 2)*.
|
|
@@ -396,7 +396,11 @@ because it has opus?". A floor is satisfied by the chosen engine's **strongest f
|
|
|
396
396
|
**Human override is inviolable — and a pin is not a cap**: if the operator pins a session default
|
|
397
397
|
(stronger or weaker), FH follows it for **session turns**; floors govern FH's **own sub-agent
|
|
398
398
|
dispatches** and a session pin does not lower them — that separation *is* the Sonnet-main +
|
|
399
|
-
Opus-dispatch doctrine (pinned-sonnet sessions still dispatch floored agents at opus).
|
|
399
|
+
Opus-dispatch doctrine (pinned-sonnet sessions still dispatch floored agents at opus). Canonical
|
|
400
|
+
axiom + defect-class + prescription ladder: `sonnet_floor_doctrine.md` (2026-07-10) — this section
|
|
401
|
+
remains the operating mechanics (F1/F2, floor governance) under that axiom; SKILL.md hard `model:`
|
|
402
|
+
pins were retired the same day (session-inherit + dispatch recommendation), agent-side dispatch
|
|
403
|
+
floors unchanged.
|
|
400
404
|
|
|
401
405
|
**Field depth-escalation (the upward complement)**: floors push *dispatches* up automatically, but
|
|
402
406
|
main-thread depth on a field session has no floor — so the templates bundle carries a Field
|