@lyupro/codex-bridge 0.6.10 → 0.6.11
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/CHANGELOG.md +81 -0
- package/README.md +15 -4
- package/cli/dispatcher-model-check.mjs +59 -0
- package/cli/doctor-host-side.mjs +2 -1
- package/cli/doctor-installation.mjs +111 -111
- package/cli/doctor.mjs +13 -5
- package/cli/handback-witness-check.mjs +24 -18
- package/cli/hosts.mjs +2 -10
- package/cli/install-owners.mjs +1 -4
- package/cli/install-record.mjs +2 -1
- package/cli/inventory-transition.mjs +1 -1
- package/cli/manifest.mjs +6 -6
- package/package.json +2 -2
- package/src/agents/codex-advisor.md +25 -119
- package/src/agents/codex-build.md +15 -133
- package/src/agents/codex-review.md +10 -109
- package/src/agents/codex-scout.md +11 -130
- package/src/home/hooks/dispatcher-gate.mjs +14 -9
- package/src/home/hooks/live-runs.mjs +0 -41
- package/src/home/hooks/order-gate.mjs +59 -40
- package/src/home/hooks/reply-guard.mjs +52 -65
- package/src/home/hooks/reply-identity.mjs +64 -0
- package/src/home/hooks/reply-verdicts.mjs +31 -27
- package/src/home/hooks/reply-witness.mjs +48 -0
- package/src/home/lib/claude-layout.mjs +14 -0
- package/src/home/lib/dispatcher-call.mjs +171 -0
- package/src/home/lib/dispatcher-command.mjs +18 -56
- package/src/home/lib/dispatcher-gate.mjs +4 -0
- package/src/home/lib/dispatcher-model.mjs +112 -0
- package/src/home/lib/dispatcher-order.mjs +21 -22
- package/src/home/lib/dispatcher-pin.mjs +60 -0
- package/src/home/lib/dispatcher-protocol.mjs +91 -0
- package/src/home/lib/dispatcher-stop.mjs +11 -2
- package/src/home/lib/handback-witness.mjs +137 -48
- package/src/home/lib/home-registry.mjs +7 -0
- package/src/home/lib/install-owner-roots.mjs +53 -0
- package/src/home/lib/meta/paths.mjs +1 -1
- package/src/home/lib/meta/reply.mjs +2 -1
- package/src/home/lib/model-family.mjs +38 -0
- package/src/home/lib/observation-ledger.mjs +145 -0
- package/src/home/lib/order-schema.mjs +204 -0
- package/src/home/lib/order-values.mjs +46 -0
- package/src/home/lib/runner/args.mjs +140 -231
- package/src/home/lib/runner/attach.mjs +3 -3
- package/src/home/lib/runner/continuation.mjs +174 -192
- package/src/home/lib/runner/launcher.mjs +9 -9
- package/src/home/lib/runner/order-options.mjs +30 -0
- package/src/home/lib/runner/order-owner.mjs +2 -1
- package/src/home/lib/runner/pass-gate.mjs +11 -9
- package/src/home/lib/runner/preflight.mjs +4 -3
- package/src/home/lib/runner/scope-check.mjs +11 -10
- package/src/home/lib/runner/task-input.mjs +23 -10
- package/src/home/lib/subagent-model-env.mjs +44 -0
- package/src/home/lib/task-header.mjs +158 -127
- package/src/home/lib/untyped-stop.mjs +30 -0
- package/src/home/lib/required-inputs.mjs +0 -301
- /package/{cli → src/home/lib}/frontmatter.mjs +0 -0
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
/** Keeps, per key, the last observation apart from the last confirmed violation and the last confirmed match.
|
|
2
|
+
* Plan_67 D8 separates these facts to cure the eternal witness warning: an undetermined observation
|
|
3
|
+
* cannot clear a violation, while a later confirmed match can. Adapters own causes and severity.
|
|
4
|
+
*/
|
|
5
|
+
import fs from 'node:fs';
|
|
6
|
+
import { writeHomeJsonAtomic } from './atomic-json.mjs';
|
|
7
|
+
import { withHomeFileLock } from './file-lock.mjs';
|
|
8
|
+
import { stateDirWriter } from './home-write.mjs';
|
|
9
|
+
import { parseJsonText } from './json-file.mjs';
|
|
10
|
+
|
|
11
|
+
export const HISTORY_LIMIT = 20;
|
|
12
|
+
const VERDICTS = new Set(['violation', 'match', 'undetermined']);
|
|
13
|
+
const has = (object, key) => Object.hasOwn(object, key);
|
|
14
|
+
const plainObject = (value) => value !== null && typeof value === 'object'
|
|
15
|
+
&& (Object.getPrototypeOf(value) === Object.prototype || Object.getPrototypeOf(value) === null);
|
|
16
|
+
|
|
17
|
+
function fields(value, required, optional = []) {
|
|
18
|
+
return plainObject(value) && required.every((key) => has(value, key))
|
|
19
|
+
&& Reflect.ownKeys(value).every((key) => required.includes(key) || optional.includes(key));
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function jsonValue(value, ancestors = new Set()) {
|
|
23
|
+
if (value === null || typeof value === 'string' || typeof value === 'boolean') return true;
|
|
24
|
+
if (typeof value === 'number') return Number.isFinite(value);
|
|
25
|
+
if ((!Array.isArray(value) && !plainObject(value)) || ancestors.has(value)) return false;
|
|
26
|
+
ancestors.add(value);
|
|
27
|
+
const valid = Array.isArray(value)
|
|
28
|
+
? Array.from(value).every((item) => jsonValue(item, ancestors))
|
|
29
|
+
: Reflect.ownKeys(value).every((key) => typeof key === 'string' && jsonValue(value[key], ancestors));
|
|
30
|
+
ancestors.delete(value);
|
|
31
|
+
return valid;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function isoTimestamp(value) {
|
|
35
|
+
if (typeof value !== 'string' || !Number.isFinite(Date.parse(value))) return false;
|
|
36
|
+
const parts = /^(\d{4}|[+-]\d{6})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})$/.exec(value);
|
|
37
|
+
if (!parts) return false;
|
|
38
|
+
const [, year, month, day, hour, minute, second] = parts.map(Number);
|
|
39
|
+
const leap = year % 4 === 0 && (year % 100 !== 0 || year % 400 === 0);
|
|
40
|
+
const days = [31, leap ? 29 : 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
|
|
41
|
+
return month >= 1 && month <= 12 && day >= 1 && day <= days[month - 1]
|
|
42
|
+
&& hour < 24 && minute < 60 && second < 60;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function validObservation(value, maxSeq) {
|
|
46
|
+
return fields(value, ['seq', 'verdict', 'at', 'detail'], ['data'])
|
|
47
|
+
&& Number.isInteger(value.seq) && value.seq > 0 && value.seq <= maxSeq
|
|
48
|
+
&& VERDICTS.has(value.verdict) && isoTimestamp(value.at) && typeof value.detail === 'string'
|
|
49
|
+
&& (!has(value, 'data') || (plainObject(value.data) && jsonValue(value.data)));
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function validEntry(entry, maxSeq) {
|
|
53
|
+
if (!fields(entry, ['lastObservation', 'lastViolation', 'lastMatch', 'history'])
|
|
54
|
+
|| !validObservation(entry.lastObservation, maxSeq)
|
|
55
|
+
|| !Array.isArray(entry.history) || entry.history.length > HISTORY_LIMIT) return false;
|
|
56
|
+
const lastSeq = entry.lastObservation.seq;
|
|
57
|
+
if (entry.lastViolation !== null && (!validObservation(entry.lastViolation, lastSeq)
|
|
58
|
+
|| entry.lastViolation.verdict !== 'violation')) return false;
|
|
59
|
+
if (entry.lastMatch !== null && (!validObservation(entry.lastMatch, lastSeq)
|
|
60
|
+
|| entry.lastMatch.verdict !== 'match')) return false;
|
|
61
|
+
let previous = 0;
|
|
62
|
+
for (const observation of entry.history) {
|
|
63
|
+
if (!validObservation(observation, lastSeq) || observation.verdict !== 'violation'
|
|
64
|
+
|| observation.seq <= previous) return false;
|
|
65
|
+
previous = observation.seq;
|
|
66
|
+
}
|
|
67
|
+
if (entry.lastViolation === null ? entry.history.length !== 0 : previous !== entry.lastViolation.seq) return false;
|
|
68
|
+
const slot = entry.lastObservation.verdict === 'violation' ? entry.lastViolation
|
|
69
|
+
: entry.lastObservation.verdict === 'match' ? entry.lastMatch : null;
|
|
70
|
+
return entry.lastObservation.verdict === 'undetermined' || slot?.seq === lastSeq;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function emptyLedger() {
|
|
74
|
+
return { format: 1, seq: 0, entries: {} };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function normalizeLedger(parsed) {
|
|
78
|
+
if (!fields(parsed, ['format', 'seq', 'entries']) || parsed.format !== 1
|
|
79
|
+
|| !Number.isInteger(parsed.seq) || parsed.seq < 0 || !plainObject(parsed.entries)) return null;
|
|
80
|
+
return Reflect.ownKeys(parsed.entries).every((key) => typeof key === 'string' && key.length > 0
|
|
81
|
+
&& validEntry(parsed.entries[key], parsed.seq)) ? parsed : null;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function reduceObservation(ledger, { key, verdict, at, detail, data }) {
|
|
85
|
+
if (!VERDICTS.has(verdict)) throw new TypeError('verdict must be violation, match or undetermined');
|
|
86
|
+
if (typeof key !== 'string' || key.length === 0) throw new TypeError('key must be a non-empty string');
|
|
87
|
+
if (!isoTimestamp(at)) throw new TypeError('at must be an ISO timestamp');
|
|
88
|
+
if (typeof detail !== 'string') throw new TypeError('detail must be a string');
|
|
89
|
+
if (data !== undefined && (!plainObject(data) || !jsonValue(data))) {
|
|
90
|
+
throw new TypeError('data must be a plain JSON object');
|
|
91
|
+
}
|
|
92
|
+
if (!normalizeLedger(ledger)) throw new TypeError('ledger must be a valid observation ledger');
|
|
93
|
+
const seq = ledger.seq + 1;
|
|
94
|
+
if (seq === ledger.seq) throw new TypeError('ledger sequence cannot increment');
|
|
95
|
+
const observation = { seq, verdict, at, detail };
|
|
96
|
+
if (data !== undefined) observation.data = structuredClone(data);
|
|
97
|
+
const previous = has(ledger.entries, key) ? ledger.entries[key]
|
|
98
|
+
: { lastObservation: null, lastViolation: null, lastMatch: null, history: [] };
|
|
99
|
+
const entry = { ...previous, lastObservation: observation };
|
|
100
|
+
if (verdict === 'violation') {
|
|
101
|
+
entry.lastViolation = observation;
|
|
102
|
+
entry.history = [...previous.history, observation].slice(-HISTORY_LIMIT);
|
|
103
|
+
} else if (verdict === 'match') {
|
|
104
|
+
entry.lastMatch = observation;
|
|
105
|
+
}
|
|
106
|
+
return { format: 1, seq, entries: { ...ledger.entries, [key]: entry } };
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export function entryState(entry) {
|
|
110
|
+
if (!entry) return 'unobserved';
|
|
111
|
+
if (entry.lastViolation) {
|
|
112
|
+
return entry.lastMatch?.seq > entry.lastViolation.seq ? 'recovered' : 'violation';
|
|
113
|
+
}
|
|
114
|
+
return entry.lastMatch ? 'clean' : 'undetermined';
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export function readLedgerFile(file, { normalize = normalizeLedger } = {}) {
|
|
118
|
+
let source;
|
|
119
|
+
try {
|
|
120
|
+
source = fs.readFileSync(file, 'utf8');
|
|
121
|
+
} catch (error) {
|
|
122
|
+
return error.code === 'ENOENT' ? emptyLedger() : { corrupt: true };
|
|
123
|
+
}
|
|
124
|
+
try {
|
|
125
|
+
return normalize(parseJsonText(file, source)) ?? { corrupt: true };
|
|
126
|
+
} catch {
|
|
127
|
+
return { corrupt: true };
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export async function updateLedgerFile({ stateDir, id, file, normalize, update }) {
|
|
132
|
+
const writer = stateDirWriter(stateDir);
|
|
133
|
+
// Plan_67 D8: allocation of seq and publication share the registered artifact's lock.
|
|
134
|
+
return withHomeFileLock(writer, id, `${file}.lock`, async () => {
|
|
135
|
+
const current = readLedgerFile(file, { normalize });
|
|
136
|
+
if (current.corrupt) throw new Error(`Cannot update corrupt observation ledger: ${file}`);
|
|
137
|
+
const next = update(current);
|
|
138
|
+
writeHomeJsonAtomic(writer, id, file, next);
|
|
139
|
+
return next;
|
|
140
|
+
}, { description: 'observation ledger' });
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export async function recordObservation({ stateDir, id, file, observation }) {
|
|
144
|
+
return updateLedgerFile({ stateDir, id, file, update: (current) => reduceObservation(current, observation) });
|
|
145
|
+
}
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Owns the dispatcher order registry for task-file headers.
|
|
3
|
+
* Plan_63 D9: the call carries only task file, so order entries belong here without runner flags.
|
|
4
|
+
* The D1/D3 `continue: none` regression still requires one schema for all header readers.
|
|
5
|
+
*/
|
|
6
|
+
import { isInputPlaceholder } from './order-values.mjs';
|
|
7
|
+
import { firstShellUnsafeSequence } from './shell-unsafe.mjs';
|
|
8
|
+
|
|
9
|
+
const freezeEntries = (entries) => Object.freeze(entries.map((entry) => Object.freeze(entry)));
|
|
10
|
+
|
|
11
|
+
export const CONTINUATION_ORDER_INPUT = Object.freeze({
|
|
12
|
+
label: 'continue',
|
|
13
|
+
explanation:
|
|
14
|
+
'The run folder this pass is ordered to continue, followed by why the orchestrator is spending another pass. A continuation is assigned by the orchestrator; after a verdict, the dispatcher returns it and stops.',
|
|
15
|
+
example: '2026-08-05_092913_plan14-build — LIMIT at step 3, tests unwritten',
|
|
16
|
+
conditional: 'when this pass continues or repeats a named run',
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
export const RETRY_ORDER_INPUT = Object.freeze({
|
|
20
|
+
label: 'retry',
|
|
21
|
+
explanation:
|
|
22
|
+
'The failed run this pass repeats, followed by why the orchestrator pays for the same pass again. A retry repeats that failed pass under its own order; it does not authorize the next pass.',
|
|
23
|
+
example: '2026-10-03_172017_cc-d66-advisor — model at capacity, same pass again',
|
|
24
|
+
conditional: 'when this pass continues or repeats a named run',
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
const REQUIRED_ORDER_INPUTS = Object.freeze({
|
|
28
|
+
'codex-scout': freezeEntries([
|
|
29
|
+
{
|
|
30
|
+
label: 'order id',
|
|
31
|
+
explanation: 'The label this order is known by. Repeating a call with the same label joins the run already in flight and costs no quota; a different piece of work needs a new label, never a reused one.',
|
|
32
|
+
example: 'plan-13-scout-20260804',
|
|
33
|
+
},
|
|
34
|
+
CONTINUATION_ORDER_INPUT,
|
|
35
|
+
RETRY_ORDER_INPUT,
|
|
36
|
+
]),
|
|
37
|
+
'codex-build': freezeEntries([
|
|
38
|
+
{
|
|
39
|
+
label: 'order id',
|
|
40
|
+
explanation: 'The label this order is known by. Repeating a call with the same label joins the run already in flight and costs no quota; a different piece of work needs a new label, never a reused one.',
|
|
41
|
+
example: 'plan-13-build-20260804',
|
|
42
|
+
},
|
|
43
|
+
{
|
|
44
|
+
label: 'scope',
|
|
45
|
+
explanation: 'Comma-separated globs relative to the repository root, listing every file the run may touch — including each caller of what changes, not only the file being edited. Anything outside the list fails the run.',
|
|
46
|
+
example: 'src/home/lib/runner/**,tests/runner/**',
|
|
47
|
+
},
|
|
48
|
+
CONTINUATION_ORDER_INPUT,
|
|
49
|
+
RETRY_ORDER_INPUT,
|
|
50
|
+
]),
|
|
51
|
+
'codex-review': freezeEntries([
|
|
52
|
+
{
|
|
53
|
+
label: 'order id',
|
|
54
|
+
explanation: 'The label this order is known by. Repeating a call with the same label joins the run already in flight and costs no quota; a different piece of work needs a new label, never a reused one.',
|
|
55
|
+
example: 'plan-13-review-20260804',
|
|
56
|
+
},
|
|
57
|
+
CONTINUATION_ORDER_INPUT,
|
|
58
|
+
RETRY_ORDER_INPUT,
|
|
59
|
+
]),
|
|
60
|
+
// Plan_59 D7: the caller must name the phase and authorize the continued decision pass.
|
|
61
|
+
'codex-advisor': freezeEntries([
|
|
62
|
+
{
|
|
63
|
+
label: 'order id',
|
|
64
|
+
explanation: 'The label this design order is known by. Keep the same order id for scope and advise so phase 2 can settle the risks predicted in phase 1.',
|
|
65
|
+
example: 'plan-59-advisor-20260922',
|
|
66
|
+
},
|
|
67
|
+
CONTINUATION_ORDER_INPUT,
|
|
68
|
+
RETRY_ORDER_INPUT,
|
|
69
|
+
{
|
|
70
|
+
label: 'phase',
|
|
71
|
+
explanation: 'Pass scope first to predict risks and check the reading boundary, then advise with a continue: grant naming the scope run of the same order.',
|
|
72
|
+
example: 'scope',
|
|
73
|
+
},
|
|
74
|
+
]),
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
export const ORDER_AGENTS = Object.freeze(Object.keys(REQUIRED_ORDER_INPUTS));
|
|
78
|
+
|
|
79
|
+
const OPTIONAL_ORDER_INPUTS = freezeEntries([
|
|
80
|
+
{
|
|
81
|
+
label: 'repository',
|
|
82
|
+
agents: ORDER_AGENTS,
|
|
83
|
+
explanation: 'The repository the runner works in. Pass its path as a labelled value so the dispatcher needs no cd.',
|
|
84
|
+
example: 'C:/work/codex-bridge',
|
|
85
|
+
},
|
|
86
|
+
{
|
|
87
|
+
label: 'scope new',
|
|
88
|
+
agents: Object.freeze(['codex-build']),
|
|
89
|
+
explanation: 'Comma-separated globs for new files this build may create, relative to the repository root.',
|
|
90
|
+
example: 'src/home/lib/dispatcher-call.mjs,tests/dispatcher-call.test.mjs',
|
|
91
|
+
},
|
|
92
|
+
{
|
|
93
|
+
label: 'slug',
|
|
94
|
+
agents: ORDER_AGENTS,
|
|
95
|
+
explanation: 'The readable name used for the run folder. It names this pass without replacing the order id.',
|
|
96
|
+
example: 'plan-76-parser',
|
|
97
|
+
},
|
|
98
|
+
{
|
|
99
|
+
label: 'effort',
|
|
100
|
+
agents: ORDER_AGENTS,
|
|
101
|
+
explanation: 'The reasoning effort assigned to this pass. The runner checks which effort values it accepts.',
|
|
102
|
+
example: 'high',
|
|
103
|
+
},
|
|
104
|
+
{
|
|
105
|
+
label: 'changeset',
|
|
106
|
+
agents: Object.freeze(['codex-review']),
|
|
107
|
+
explanation: 'The changes to review: uncommitted, base:<branch>, or commit:<sha>. Name only the ordered changeset.',
|
|
108
|
+
example: 'base:main',
|
|
109
|
+
},
|
|
110
|
+
]);
|
|
111
|
+
|
|
112
|
+
const schemas = new Map(ORDER_AGENTS.map((agentType) => [agentType,
|
|
113
|
+
freezeEntries([
|
|
114
|
+
...REQUIRED_ORDER_INPUTS[agentType],
|
|
115
|
+
...OPTIONAL_ORDER_INPUTS.filter((entry) => entry.agents.includes(agentType))
|
|
116
|
+
.map((entry) => ({ ...entry, optional: true })),
|
|
117
|
+
].map((entry) => ({
|
|
118
|
+
...entry,
|
|
119
|
+
required: !entry.conditional && !entry.optional,
|
|
120
|
+
conditional: Boolean(entry.conditional),
|
|
121
|
+
optional: Boolean(entry.optional),
|
|
122
|
+
}))),
|
|
123
|
+
]));
|
|
124
|
+
const NO_LABELS = Object.freeze([]);
|
|
125
|
+
|
|
126
|
+
export function orderLabelsFor(agentType) {
|
|
127
|
+
return schemas.get(agentType) ?? NO_LABELS;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export const ALL_ORDER_LABELS = Object.freeze([...new Set(
|
|
131
|
+
ORDER_AGENTS.flatMap((agentType) => orderLabelsFor(agentType).map(({ label }) => label)),
|
|
132
|
+
)].sort());
|
|
133
|
+
|
|
134
|
+
const inputNames = new Set(ALL_ORDER_LABELS);
|
|
135
|
+
|
|
136
|
+
// Plan_63 D7: switching the order input channel changes only this function.
|
|
137
|
+
export function orderInputName(label) {
|
|
138
|
+
if (!inputNames.has(label)) throw new Error(`Unknown order input label "${label}"`);
|
|
139
|
+
return `\`${label}:\``;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export function orderFromHeader(agentType, parsed) {
|
|
143
|
+
const order = new Map();
|
|
144
|
+
const problems = [];
|
|
145
|
+
if (!schemas.has(agentType)) {
|
|
146
|
+
problems.push({ lineNo: null, line: '', reason: `unknown dispatcher agent "${agentType}"` });
|
|
147
|
+
return { order, problems };
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const labels = orderLabelsFor(agentType);
|
|
151
|
+
const accepted = new Set(labels.map(({ label }) => label));
|
|
152
|
+
for (const [label, value] of Object.entries(parsed.fields)) {
|
|
153
|
+
if (!ALL_ORDER_LABELS.includes(label)) continue;
|
|
154
|
+
// parseTaskHeader retains the first duplicate value; diagnostics must name that same line.
|
|
155
|
+
const entry = parsed.entries?.find((entry) => entry.label === label);
|
|
156
|
+
const location = entry ? { lineNo: entry.lineNo, line: entry.line }
|
|
157
|
+
: { lineNo: null, line: '' };
|
|
158
|
+
if (!accepted.has(label)) {
|
|
159
|
+
problems.push({ ...location, reason: `label "${label}" is not accepted by ${agentType}` });
|
|
160
|
+
continue;
|
|
161
|
+
}
|
|
162
|
+
// Grant values are validated by parseTaskHeader; re-reporting them would duplicate its problem.
|
|
163
|
+
if (label === 'continue' || label === 'retry') continue;
|
|
164
|
+
order.set(label, value);
|
|
165
|
+
if (isInputPlaceholder(value, label)) {
|
|
166
|
+
problems.push({ ...location, reason: `label "${label}" is still a placeholder` });
|
|
167
|
+
}
|
|
168
|
+
// Plan_63 D5: the flag-era refusal (Plan_42) keeps its pair in the header — an order value is a short
|
|
169
|
+
// identifier, so a shell sequence in it is free text written in the wrong place.
|
|
170
|
+
const sequence = firstShellUnsafeSequence(value);
|
|
171
|
+
if (sequence !== null) {
|
|
172
|
+
problems.push({ ...location, reason: `${orderInputName(label)} contains forbidden shell sequence ` +
|
|
173
|
+
`${JSON.stringify(sequence)}; put free text in the task file body and keep header values short` });
|
|
174
|
+
}
|
|
175
|
+
// Plan_63 D5: new paths cannot replace the existing-file boundary of a writing order.
|
|
176
|
+
if (agentType === 'codex-build' && label === 'scope'
|
|
177
|
+
&& !value.split(',').some((pattern) => pattern.trim())) {
|
|
178
|
+
problems.push({ ...location,
|
|
179
|
+
reason: `${orderInputName(label)} is required for codex-build: declare at least one existing path pattern; ` +
|
|
180
|
+
`${orderInputName('scope new')} does not replace it.` });
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
for (const { label, required, example } of labels) {
|
|
184
|
+
if (required && !Object.hasOwn(parsed.fields, label)) {
|
|
185
|
+
problems.push({ lineNo: null, line: '',
|
|
186
|
+
reason: `missing required header label "${label}:"; example: ${label}: ${example}` });
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
return { order, problems };
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
export function renderOrderProblems(agentType, problems) {
|
|
193
|
+
return [...problems.map(({ lineNo, line, reason }) => lineNo === null
|
|
194
|
+
? reason : `line ${lineNo}: ${reason}: ${line}`), renderOrderHeaderHelp(agentType)].join('\n');
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
export function renderOrderHeaderHelp(agentType) {
|
|
198
|
+
const labels = orderLabelsFor(agentType);
|
|
199
|
+
return [
|
|
200
|
+
...labels.filter(({ required }) => required),
|
|
201
|
+
...labels.filter(({ conditional }) => conditional),
|
|
202
|
+
...labels.filter(({ optional }) => optional),
|
|
203
|
+
].map(({ label, example }) => `${label}: ${example}`).join('\n');
|
|
204
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Owns one responsibility: the grammar of a single order value.
|
|
3
|
+
* Plan_63 D9: call and header readers share this leaf without importing either registry.
|
|
4
|
+
*/
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
|
|
7
|
+
const PLACEHOLDER_VALUES = new Set(['todo', 'tbd', 'label', 'none', 'order id', 'scope', 'xxx']);
|
|
8
|
+
|
|
9
|
+
function cleanValue(value) {
|
|
10
|
+
if (typeof value !== 'string') return '';
|
|
11
|
+
return value.trim().replace(/^([`'\"])(.*)\1$/, '$2').trim();
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function isAbsoluteTaskFilePath(value) {
|
|
15
|
+
const cleaned = cleanValue(value);
|
|
16
|
+
return path.posix.isAbsolute(cleaned) || path.win32.isAbsolute(cleaned);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function isPlaceholder(value) {
|
|
20
|
+
const cleaned = cleanValue(value);
|
|
21
|
+
if (!cleaned) return true;
|
|
22
|
+
if (cleaned.startsWith('<') && cleaned.endsWith('>')) return true;
|
|
23
|
+
return PLACEHOLDER_VALUES.has(cleaned.replace(/[.,;:]+$/, '').trim().toLowerCase());
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// Plan_59: scope is a real phase value, while it remains a placeholder for existing inputs. Plan_63 D5: none is a
|
|
27
|
+
// real reasoning effort the flag channel always accepted, so the header channel must not refuse it as a template.
|
|
28
|
+
const REAL_VALUES = Object.freeze({ phase: 'scope', effort: 'none' });
|
|
29
|
+
export const isInputPlaceholder = (value, label) =>
|
|
30
|
+
!(Object.hasOwn(REAL_VALUES, label) && cleanValue(value) === REAL_VALUES[label]) && isPlaceholder(value);
|
|
31
|
+
|
|
32
|
+
// Plan_75 D5, 2026-10-03 20:42: header parsing must reuse the existing grant value grammar.
|
|
33
|
+
export function splitGrantValue(value) {
|
|
34
|
+
if (isPlaceholder(value)) return null;
|
|
35
|
+
|
|
36
|
+
for (const separator of [/\s+—\s+/, /\s+-\s+/, /\s*:\s*/]) {
|
|
37
|
+
const match = value.match(new RegExp(`^(.+?)${separator.source}(.+)$`));
|
|
38
|
+
if (!match) continue;
|
|
39
|
+
const run = cleanValue(match[1]);
|
|
40
|
+
const reason = cleanValue(match[2]);
|
|
41
|
+
if (isPlaceholder(run) || isPlaceholder(reason)) return null;
|
|
42
|
+
return { run, reason };
|
|
43
|
+
}
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
46
|
+
|