gcf-aif-ext 0.7.4 → 0.7.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +7 -1
- package/commands/gcf.js +25 -0
- package/extension.json +1 -1
- package/lib/agent-permissions.js +243 -0
- package/lib/board.js +37 -2
- package/lib/gcf-paths.js +18 -0
- package/lib/session-mark.js +8 -0
- package/lib/work-ops.js +4 -2
- package/package.json +1 -1
- package/templates/agents-block.md +1 -0
- package/templates/config.yml +8 -0
package/README.md
CHANGED
|
@@ -50,9 +50,15 @@ Updating preserves `gcf/`, `cnlp.config.json` and the pre-commit hook. Run `ai-f
|
|
|
50
50
|
|
|
51
51
|
On Windows, run `extension add` and `extension update` from PowerShell or cmd, not Git Bash: the installer unpacks with `tar`, and Git Bash's GNU tar reads `C:\…` as a remote host (`Cannot connect to C: resolve failed`). The Windows `tar.exe` handles it.
|
|
52
52
|
|
|
53
|
+
### 0.7.6
|
|
54
|
+
|
|
55
|
+
- `ai-factory gcf permissions [--on <group>] [--off <group>] [--deny <rule>] [--undeny <rule>]` — what agent runs may not do: deny groups and the human's own rules in the `agent:` section of `gcf/config.yml`, one `gcf(permissions)` commit; with no change it lists them. On by default: `git-destroy`, `mass-delete`, `publish`, `human-moves`, `guards`; off: `secrets`, `outside`, `processes`. `agent.groups` keeps only the human's choices (`secrets: true`): a group it does not name keeps its default, so one a later version adds comes on. `git push` is in none. `gcf board --json` carries `agent` (`groups`, `deny_extra`, the resolved `deny`, `allow`).
|
|
56
|
+
- New `config.yml` keys for harness runs: `context_handoff_pct` (60; 0 turns the handoff off), `context_window` (0: the model's) and `auto_compact_pct` (0: the agent's default; at least the handoff share + 15). `gcf board --json` carries them as `context`; a kept session that handed over reads `resume: {ended: 'handoff', handoffs}`.
|
|
57
|
+
- The AGENTS.md block tells the agent that the permissions are the human's.
|
|
58
|
+
|
|
53
59
|
### 0.7.1
|
|
54
60
|
|
|
55
|
-
- `gcf board --json`: a ready item's worktree with no run lock and no session mark is a run that stopped (a timeout, a stop, a crash), not a run: the item reads `running: false`, `resumable: true`, and keeps its moves; its next run resumes the worktree.
|
|
61
|
+
- `gcf board --json`: a ready item's worktree with no run lock and no session mark is a run that stopped (a timeout, a stop, a crash), not a run: the item reads `running: false`, `resumable: true`, and keeps its moves; its next run resumes the worktree. An agent session the harness kept (`.gcf-session/runs/<id>.agent.json`) makes a ready item `resumable` too, in either run mode, and shows as `resume: {ended, resets?}` (null without one).
|
|
56
62
|
|
|
57
63
|
### 0.7.0
|
|
58
64
|
|
package/commands/gcf.js
CHANGED
|
@@ -977,6 +977,31 @@ function registerBoard(gcf) {
|
|
|
977
977
|
].join('\n'),
|
|
978
978
|
);
|
|
979
979
|
});
|
|
980
|
+
const many = (v, prev = []) => [...prev, v];
|
|
981
|
+
gcf
|
|
982
|
+
.command('permissions')
|
|
983
|
+
.description("What agent runs may not do: deny groups and custom rules (the human's; commit)")
|
|
984
|
+
.option('--on <group>', 'turn a deny group on (repeatable)', many)
|
|
985
|
+
.option('--off <group>', 'turn a deny group off (repeatable)', many)
|
|
986
|
+
.option('--deny <rule>', 'add a custom deny rule, like Bash(cmd*) (repeatable)', many)
|
|
987
|
+
.option('--undeny <rule>', 'remove a custom deny rule (repeatable)', many)
|
|
988
|
+
.option('--json', 'machine output')
|
|
989
|
+
.option('--no-commit', 'skip the commit (tests only)')
|
|
990
|
+
.action(async (o) => {
|
|
991
|
+
const perms = await lib('agent-permissions');
|
|
992
|
+
const { gcfRoot, readConfig } = await lib('gcf-paths');
|
|
993
|
+
const change = [o.on, o.off, o.deny, o.undeny].some(Boolean);
|
|
994
|
+
const r = change
|
|
995
|
+
? await perms.setPermissions(process.cwd(), o, { commit: o.commit })
|
|
996
|
+
: perms.agentOf(await readConfig(gcfRoot()));
|
|
997
|
+
out(o.json, r, (x) =>
|
|
998
|
+
[
|
|
999
|
+
...x.groups.map((g) => `${g.on ? '[x]' : '[ ]'} ${g.id}: ${g.label}`),
|
|
1000
|
+
...x.deny_extra.map((d) => `deny: ${d}`),
|
|
1001
|
+
...(x.hash ? [`committed${shortHash(x.hash)}`] : []),
|
|
1002
|
+
].join('\n'),
|
|
1003
|
+
);
|
|
1004
|
+
});
|
|
980
1005
|
}
|
|
981
1006
|
|
|
982
1007
|
function registerIntegrate(gcf) {
|
package/extension.json
CHANGED
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
// What a headless agent run may not do (Harness-PRD §7). The run allows everything else
|
|
2
|
+
// (`--permission-mode bypassPermissions`): a deny rule holds in every mode. The groups guard
|
|
3
|
+
// the edge cases only — what git cannot undo, what leaves the machine, the human's own moves
|
|
4
|
+
// and the guards themselves. `git push` is not one of them: a feature ends tested and pushed.
|
|
5
|
+
// Deny rules match the command as written, so they catch accidents, not a determined
|
|
6
|
+
// rewording; the gcf-run skill makes a refusal a stop.
|
|
7
|
+
import { readFile } from 'node:fs/promises';
|
|
8
|
+
import { existsSync } from 'node:fs';
|
|
9
|
+
import path from 'node:path';
|
|
10
|
+
import YAML from 'yaml';
|
|
11
|
+
import { commitMove } from './unit-ops.js';
|
|
12
|
+
import { atomicWrite } from './safe-path.js';
|
|
13
|
+
import { gcfRoot } from './gcf-paths.js';
|
|
14
|
+
|
|
15
|
+
// A shell command pattern turns into a rule for both shell tools: on Windows the agent runs
|
|
16
|
+
// commands through PowerShell, which Bash rules do not cover.
|
|
17
|
+
const shell = (...patterns) => patterns.flatMap((p) => [`Bash(${p})`, `PowerShell(${p})`]);
|
|
18
|
+
const files = (tools, ...globs) => globs.flatMap((g) => tools.map((t) => `${t}(${g})`));
|
|
19
|
+
|
|
20
|
+
/** @type {{ id: string, label: string, on: boolean, rules: string[] }[]} */
|
|
21
|
+
export const DENY_GROUPS = [
|
|
22
|
+
{
|
|
23
|
+
id: 'git-destroy',
|
|
24
|
+
label: 'git history and uncommitted work: reset --hard, clean, stash, force push',
|
|
25
|
+
on: true,
|
|
26
|
+
rules: shell(
|
|
27
|
+
'git reset --hard*',
|
|
28
|
+
'git clean*',
|
|
29
|
+
'git stash*',
|
|
30
|
+
'git branch -D*',
|
|
31
|
+
'git filter-branch*',
|
|
32
|
+
'git update-ref -d*',
|
|
33
|
+
'git push*--force*',
|
|
34
|
+
'git push -f*',
|
|
35
|
+
'git push -uf*',
|
|
36
|
+
'git push * -f',
|
|
37
|
+
'git push * -f *',
|
|
38
|
+
'git push *+*',
|
|
39
|
+
'git push*--mirror*',
|
|
40
|
+
'git push*--delete*',
|
|
41
|
+
'git checkout .',
|
|
42
|
+
'git checkout -- .',
|
|
43
|
+
'git restore .',
|
|
44
|
+
'git restore -- .',
|
|
45
|
+
),
|
|
46
|
+
},
|
|
47
|
+
{
|
|
48
|
+
id: 'mass-delete',
|
|
49
|
+
label: 'recursive delete: rm -r, Remove-Item -Recurse, rd /s',
|
|
50
|
+
on: true,
|
|
51
|
+
rules: shell(
|
|
52
|
+
'rm -r*',
|
|
53
|
+
'rm -R*',
|
|
54
|
+
'rm -fr*',
|
|
55
|
+
'rm -fR*',
|
|
56
|
+
'rm -f -r*',
|
|
57
|
+
'rm --recursive*',
|
|
58
|
+
'Remove-Item*-Recurse*',
|
|
59
|
+
'Remove-Item* -r',
|
|
60
|
+
'Remove-Item* -r *',
|
|
61
|
+
'rmdir /s*',
|
|
62
|
+
'rd /s*',
|
|
63
|
+
'del /s*',
|
|
64
|
+
'cmd /c rd /s*',
|
|
65
|
+
'cmd /c rmdir /s*',
|
|
66
|
+
'cmd /c del /s*',
|
|
67
|
+
),
|
|
68
|
+
},
|
|
69
|
+
{
|
|
70
|
+
id: 'publish',
|
|
71
|
+
label: 'publishing: npm publish, stage, unpublish, deprecate; docker push',
|
|
72
|
+
on: true,
|
|
73
|
+
rules: shell('npm publish*', 'npm stage*', 'npm unpublish*', 'npm deprecate*', 'docker push*'),
|
|
74
|
+
},
|
|
75
|
+
{
|
|
76
|
+
id: 'human-moves',
|
|
77
|
+
label:
|
|
78
|
+
"the human's GCF moves: verdict, decide, answer, unblock, phase, pillars, permissions, setup",
|
|
79
|
+
on: true,
|
|
80
|
+
rules: shell(
|
|
81
|
+
'ai-factory gcf verdict*',
|
|
82
|
+
'ai-factory gcf decide*',
|
|
83
|
+
'ai-factory gcf question answer*',
|
|
84
|
+
'ai-factory gcf unblock*',
|
|
85
|
+
'ai-factory gcf phase next*',
|
|
86
|
+
'ai-factory gcf pillar*',
|
|
87
|
+
'ai-factory gcf permissions*',
|
|
88
|
+
'ai-factory gcf hooks-install*',
|
|
89
|
+
'ai-factory gcf-harness init*',
|
|
90
|
+
'ai-factory gcf-harness run*',
|
|
91
|
+
'ai-factory gcf-harness stop*',
|
|
92
|
+
'ai-factory extension*',
|
|
93
|
+
),
|
|
94
|
+
},
|
|
95
|
+
{
|
|
96
|
+
id: 'guards',
|
|
97
|
+
label: 'the guards: .claude/, extension code, gcf/LIVE.md, gcf/.metrics/, the run files',
|
|
98
|
+
on: true,
|
|
99
|
+
rules: files(
|
|
100
|
+
['Edit', 'Write'],
|
|
101
|
+
'.claude/**',
|
|
102
|
+
'.ai-factory/extensions/**',
|
|
103
|
+
'gcf/LIVE.md',
|
|
104
|
+
'gcf/.metrics/**',
|
|
105
|
+
'.gcf-session/runs/**',
|
|
106
|
+
'.gcf-session/handoff.json',
|
|
107
|
+
),
|
|
108
|
+
},
|
|
109
|
+
{
|
|
110
|
+
id: 'secrets',
|
|
111
|
+
label: 'reading secrets: ~/.npmrc, ~/.ssh, ~/.claude.json, .env',
|
|
112
|
+
on: false,
|
|
113
|
+
rules: files(['Read'], '~/.npmrc', '~/.ssh/**', '~/.claude.json', '**/.env'),
|
|
114
|
+
},
|
|
115
|
+
{
|
|
116
|
+
id: 'outside',
|
|
117
|
+
label:
|
|
118
|
+
'changes outside the project: global installs, git config --global, setx, registry, shutdown',
|
|
119
|
+
on: false,
|
|
120
|
+
rules: shell(
|
|
121
|
+
'npm i -g*',
|
|
122
|
+
'npm install -g*',
|
|
123
|
+
'npm install --global*',
|
|
124
|
+
'git config --global*',
|
|
125
|
+
'setx*',
|
|
126
|
+
'reg add*',
|
|
127
|
+
'reg delete*',
|
|
128
|
+
'shutdown*',
|
|
129
|
+
'Restart-Computer*',
|
|
130
|
+
'Stop-Computer*',
|
|
131
|
+
),
|
|
132
|
+
},
|
|
133
|
+
{
|
|
134
|
+
id: 'processes',
|
|
135
|
+
label: 'killing processes: taskkill, Stop-Process, kill',
|
|
136
|
+
on: false,
|
|
137
|
+
rules: shell('taskkill*', 'Stop-Process*', 'kill *', 'pkill*'),
|
|
138
|
+
},
|
|
139
|
+
];
|
|
140
|
+
|
|
141
|
+
const GROUP_IDS = DENY_GROUPS.map((g) => g.id);
|
|
142
|
+
// A permission rule: a tool name, optionally with a specifier — `Bash(npm test*)`, `Read`.
|
|
143
|
+
const RULE_RE = /^[A-Za-z_][\w-]*(\(.+\))?$/;
|
|
144
|
+
const known = (x) => GROUP_IDS.includes(x);
|
|
145
|
+
const rule = (x) => typeof x === 'string' && RULE_RE.test(x);
|
|
146
|
+
|
|
147
|
+
function rules(agent, key) {
|
|
148
|
+
const v = agent[key] ?? [];
|
|
149
|
+
if (!Array.isArray(v) || !v.every(rule))
|
|
150
|
+
throw new Error(`gcf/config.yml: agent.${key} must be a list of rules like Bash(cmd*) or Read`);
|
|
151
|
+
|
|
152
|
+
return v;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* The groups the human chose: `agent.groups` maps a group id to true or false. A group it does
|
|
157
|
+
* not name keeps its default, so a group a later version adds on comes on in every project.
|
|
158
|
+
*/
|
|
159
|
+
function choices(agent) {
|
|
160
|
+
const g = agent.groups ?? {};
|
|
161
|
+
if (typeof g !== 'object' || Array.isArray(g))
|
|
162
|
+
throw new Error('gcf/config.yml: agent.groups must map a group id to true or false');
|
|
163
|
+
for (const [id, on] of Object.entries(g))
|
|
164
|
+
if (!known(id) || typeof on !== 'boolean')
|
|
165
|
+
throw new Error(
|
|
166
|
+
`gcf/config.yml: agent.groups.${id} must be true or false, for one of ${GROUP_IDS.join(' | ')}`,
|
|
167
|
+
);
|
|
168
|
+
|
|
169
|
+
return g;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* The `agent:` section resolved: each group on or off, the deny rules the run gets, the extra
|
|
174
|
+
* allow rules (for an agent run without bypass). No section: the default groups.
|
|
175
|
+
*/
|
|
176
|
+
export function agentOf(config) {
|
|
177
|
+
const agent = config.agent ?? {};
|
|
178
|
+
if (typeof agent !== 'object' || Array.isArray(agent))
|
|
179
|
+
throw new Error('gcf/config.yml: agent must be a mapping');
|
|
180
|
+
const chosen = choices(agent);
|
|
181
|
+
const extra = rules(agent, 'deny');
|
|
182
|
+
const allow = rules(agent, 'allow');
|
|
183
|
+
const groups = DENY_GROUPS.map((g) => ({ id: g.id, label: g.label, on: chosen[g.id] ?? g.on }));
|
|
184
|
+
const deny = [
|
|
185
|
+
...new Set([...DENY_GROUPS.filter((g, i) => groups[i].on).flatMap((g) => g.rules), ...extra]),
|
|
186
|
+
];
|
|
187
|
+
|
|
188
|
+
return { groups, deny_extra: extra, deny, allow };
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/** Refuse a change that names no group, a group twice, a bad rule, or a rule it cannot remove. */
|
|
192
|
+
function checkChange(change, current) {
|
|
193
|
+
const on = change.on ?? [];
|
|
194
|
+
const off = change.off ?? [];
|
|
195
|
+
for (const id of [...on, ...off])
|
|
196
|
+
if (!known(id)) throw new Error(`no permission group ${id}: ${GROUP_IDS.join(' | ')}`);
|
|
197
|
+
const both = on.find((id) => off.includes(id));
|
|
198
|
+
if (both) throw new Error(`${both} is turned on and off at once`);
|
|
199
|
+
for (const r of [...(change.deny ?? []), ...(change.undeny ?? [])])
|
|
200
|
+
if (!rule(r)) throw new Error(`not a permission rule: ${r}`);
|
|
201
|
+
const missing = (change.undeny ?? []).find((r) => !current.deny_extra.includes(r));
|
|
202
|
+
if (missing) throw new Error(`${missing} is not one of your own deny rules: turn its group off`);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Turn groups on or off, add or remove a custom rule; commit gcf(permissions). Only the
|
|
207
|
+
* `agent:` section changes: the rest of config.yml keeps its keys and comments. The file is
|
|
208
|
+
* read under the move's lock, so two changes at once both land.
|
|
209
|
+
*/
|
|
210
|
+
export async function setPermissions(projectDir, change, { commit = true } = {}) {
|
|
211
|
+
const file = path.join(gcfRoot(projectDir), 'config.yml');
|
|
212
|
+
let result;
|
|
213
|
+
const hash = await commitMove(projectDir, 'gcf(permissions): agent deny rules', {
|
|
214
|
+
files: [file],
|
|
215
|
+
mutate: async () => {
|
|
216
|
+
const doc = YAML.parseDocument(existsSync(file) ? await readFile(file, 'utf8') : '');
|
|
217
|
+
if (doc.errors.length) throw new Error(`gcf/config.yml: ${doc.errors[0].message}`);
|
|
218
|
+
if (!YAML.isMap(doc.contents)) doc.contents = doc.createNode({});
|
|
219
|
+
// A blank `agent:` (or one holding only comments) is no map yet.
|
|
220
|
+
const agent = doc.get('agent', true);
|
|
221
|
+
if (agent != null && !YAML.isMap(agent) && !(YAML.isScalar(agent) && agent.value == null))
|
|
222
|
+
throw new Error('gcf/config.yml: agent must be a mapping');
|
|
223
|
+
if (!YAML.isMap(agent)) doc.set('agent', doc.createNode({}));
|
|
224
|
+
const groups = doc.getIn(['agent', 'groups'], true);
|
|
225
|
+
if (groups != null && !YAML.isMap(groups) && !(YAML.isScalar(groups) && groups.value == null))
|
|
226
|
+
throw new Error('gcf/config.yml: agent.groups must map a group id to true or false');
|
|
227
|
+
if (!YAML.isMap(groups)) doc.setIn(['agent', 'groups'], doc.createNode({}));
|
|
228
|
+
const current = agentOf({ agent: doc.toJS().agent });
|
|
229
|
+
checkChange(change, current);
|
|
230
|
+
for (const id of change.on ?? []) doc.setIn(['agent', 'groups', id], true);
|
|
231
|
+
for (const id of change.off ?? []) doc.setIn(['agent', 'groups', id], false);
|
|
232
|
+
const deny = [...new Set([...current.deny_extra, ...(change.deny ?? [])])].filter(
|
|
233
|
+
(r) => !change.undeny?.includes(r),
|
|
234
|
+
);
|
|
235
|
+
doc.setIn(['agent', 'deny'], doc.createNode(deny, { flow: true }));
|
|
236
|
+
result = agentOf({ agent: doc.toJS().agent });
|
|
237
|
+
await atomicWrite(file, doc.toString());
|
|
238
|
+
},
|
|
239
|
+
commit,
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
return { ...result, hash };
|
|
243
|
+
}
|
package/lib/board.js
CHANGED
|
@@ -16,6 +16,7 @@ import { ITEM_TYPES } from './validate-item.js';
|
|
|
16
16
|
import { movesOf } from './transitions.js';
|
|
17
17
|
import { listQuestions } from './questions.js';
|
|
18
18
|
import { sessionActive } from './session-mark.js';
|
|
19
|
+
import { agentOf } from './agent-permissions.js';
|
|
19
20
|
|
|
20
21
|
// The version of the JSON below (Methodology §11): a board refuses one it does not know. Bump it
|
|
21
22
|
// when a field changes meaning or goes away; a new field does not bump it.
|
|
@@ -120,6 +121,27 @@ function logOf(projectDir, id) {
|
|
|
120
121
|
return existsSync(path.join(projectDir, log)) ? log : null;
|
|
121
122
|
}
|
|
122
123
|
|
|
124
|
+
/**
|
|
125
|
+
* How the agent session of the item's last run ended, when the harness kept it for a rerun to
|
|
126
|
+
* resume (`<id>.agent.json`): `{ended, resets?}`, or null. A file that is not a record is none.
|
|
127
|
+
*/
|
|
128
|
+
async function resumeOf(projectDir, id) {
|
|
129
|
+
try {
|
|
130
|
+
const file = path.join(projectDir, '.gcf-session', 'runs', `${id}.agent.json`);
|
|
131
|
+
const rec = JSON.parse(await readFile(file, 'utf8'));
|
|
132
|
+
// A handoff needs no session: the next run starts a fresh one from the plan.
|
|
133
|
+
if (typeof rec?.session_id !== 'string' && rec?.ended !== 'handoff') return null;
|
|
134
|
+
|
|
135
|
+
return {
|
|
136
|
+
ended: String(rec.ended ?? ''),
|
|
137
|
+
...(rec.resets ? { resets: String(rec.resets) } : {}),
|
|
138
|
+
...(rec.handoffs ? { handoffs: Number(rec.handoffs) } : {}),
|
|
139
|
+
};
|
|
140
|
+
} catch {
|
|
141
|
+
return null;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
123
145
|
// Fields of the item file a board shows as they are: null or [] when the file has none.
|
|
124
146
|
const SCALARS = [
|
|
125
147
|
'title',
|
|
@@ -163,10 +185,15 @@ async function itemOf(projectDir, c, verified) {
|
|
|
163
185
|
const ready = c.data.status === 'ready' && verified.has(id);
|
|
164
186
|
// `running` and `verify` are not stored (§4): a live harness run lock or a fresh session mark
|
|
165
187
|
// is a run; a trailer is a passed DoD. A worktree with neither is a run that stopped (a
|
|
166
|
-
// timeout, a stop, a crash): the item is ready, and its next run resumes that worktree.
|
|
188
|
+
// timeout, a stop, a crash): the item is ready, and its next run resumes that worktree. A
|
|
189
|
+
// session the harness kept (`resume`) is resumed by the next run in either mode.
|
|
167
190
|
const running = runHolder(projectDir, id) !== null || sessionActive(projectDir, id);
|
|
191
|
+
const resume = c.data.status === 'ready' ? await resumeOf(projectDir, id) : null;
|
|
168
192
|
const resumable =
|
|
169
|
-
!running &&
|
|
193
|
+
!running &&
|
|
194
|
+
c.data.status === 'ready' &&
|
|
195
|
+
!ready &&
|
|
196
|
+
(resume !== null || existsSync(worktreeOf(projectDir, id)));
|
|
170
197
|
|
|
171
198
|
return {
|
|
172
199
|
id,
|
|
@@ -179,6 +206,7 @@ async function itemOf(projectDir, c, verified) {
|
|
|
179
206
|
...(c.data.blocked ? { blocked: c.data.blocked } : {}),
|
|
180
207
|
running,
|
|
181
208
|
resumable,
|
|
209
|
+
resume,
|
|
182
210
|
verified: ready,
|
|
183
211
|
// What a board may offer (§4.1): nothing while a run drives the item.
|
|
184
212
|
moves: running ? [] : movesOf(c.data),
|
|
@@ -202,6 +230,13 @@ export async function board(projectDir) {
|
|
|
202
230
|
return {
|
|
203
231
|
schema: BOARD_SCHEMA,
|
|
204
232
|
run_mode: config.run_mode,
|
|
233
|
+
// What the harness hands its agent runs (Harness-PRD §7): the deny rules and the context limits.
|
|
234
|
+
agent: agentOf(config),
|
|
235
|
+
context: {
|
|
236
|
+
handoff_pct: config.context_handoff_pct,
|
|
237
|
+
window: config.context_window,
|
|
238
|
+
compact_pct: config.auto_compact_pct,
|
|
239
|
+
},
|
|
205
240
|
phase: await phaseOf(projectDir),
|
|
206
241
|
// A malformed block reads with fields missing (live check names it): null, not absent.
|
|
207
242
|
release: releaseOf(live.release),
|
package/lib/gcf-paths.js
CHANGED
|
@@ -16,8 +16,16 @@ const DEFAULT_CONFIG = {
|
|
|
16
16
|
unplayed_warn_after: 5,
|
|
17
17
|
plan_passes: 5,
|
|
18
18
|
dod_attempts: 2,
|
|
19
|
+
// A run hands its item to a fresh session at this share of the context window (0: never);
|
|
20
|
+
// the window comes from the model unless context_window sets it (Harness-PRD §7).
|
|
21
|
+
context_handoff_pct: 60,
|
|
22
|
+
context_window: 0,
|
|
23
|
+
// CLAUDE_AUTOCOMPACT_PCT_OVERRIDE for the run (0: the agent's own default).
|
|
24
|
+
auto_compact_pct: 0,
|
|
19
25
|
};
|
|
20
26
|
|
|
27
|
+
const PCT_KEYS = ['context_handoff_pct', 'auto_compact_pct'];
|
|
28
|
+
|
|
21
29
|
/** `gcf/` lives at the repository root (GCF-PRD §5.1). Throws when absent. */
|
|
22
30
|
export function gcfRoot(projectDir = process.cwd()) {
|
|
23
31
|
const root = path.join(path.resolve(projectDir), 'gcf');
|
|
@@ -41,6 +49,16 @@ function runModeOf(v = 'worktree') {
|
|
|
41
49
|
/** @returns {Promise<Record<string, any>>} */
|
|
42
50
|
export async function readConfig(root) {
|
|
43
51
|
const config = await readNumbers(root);
|
|
52
|
+
for (const key of PCT_KEYS)
|
|
53
|
+
if (config[key] > 100)
|
|
54
|
+
throw new Error(`gcf/config.yml: ${key} is a share of the window, 0-100, got ${config[key]}`);
|
|
55
|
+
// A compaction before the handoff threshold shrinks the context the handoff waits for.
|
|
56
|
+
const { auto_compact_pct: compact, context_handoff_pct: handoff } = config;
|
|
57
|
+
if (compact && handoff && compact < handoff + 15)
|
|
58
|
+
throw new Error(
|
|
59
|
+
`gcf/config.yml: auto_compact_pct ${compact} must be at least context_handoff_pct + 15 ` +
|
|
60
|
+
`(${handoff + 15}), or the compaction comes before the handoff`,
|
|
61
|
+
);
|
|
44
62
|
|
|
45
63
|
return { ...config, run_mode: runModeOf(config.run_mode) };
|
|
46
64
|
}
|
package/lib/session-mark.js
CHANGED
|
@@ -40,6 +40,14 @@ export function releaseSession(projectDir, id) {
|
|
|
40
40
|
rmSync(markOf(projectDir, id), { force: true });
|
|
41
41
|
}
|
|
42
42
|
|
|
43
|
+
/**
|
|
44
|
+
* Forget the agent session the harness kept for a rerun of `id` (`<id>.agent.json`): a move,
|
|
45
|
+
* by hand or by an agent, ends the work it would resume.
|
|
46
|
+
*/
|
|
47
|
+
export function dropAgentSession(projectDir, id) {
|
|
48
|
+
rmSync(path.join(path.dirname(markOf(projectDir, id)), `${id}.agent.json`), { force: true });
|
|
49
|
+
}
|
|
50
|
+
|
|
43
51
|
/** Whether a session works the item now: a mark refreshed within SESSION_TTL_MS. */
|
|
44
52
|
export function sessionActive(projectDir, id) {
|
|
45
53
|
const file = markOf(projectDir, id);
|
package/lib/work-ops.js
CHANGED
|
@@ -38,7 +38,7 @@ import {
|
|
|
38
38
|
import { dropInbox } from './inbox.js';
|
|
39
39
|
import { refuseByPhase } from './phase.js';
|
|
40
40
|
import { runHolder, withMainLock } from './main-lock.js';
|
|
41
|
-
import { releaseSession, sessionActive } from './session-mark.js';
|
|
41
|
+
import { dropAgentSession, releaseSession, sessionActive } from './session-mark.js';
|
|
42
42
|
|
|
43
43
|
/**
|
|
44
44
|
* Options of a committed move; each move reads the ones it needs.
|
|
@@ -228,8 +228,10 @@ async function finish(m, move, tail, commit) {
|
|
|
228
228
|
commit,
|
|
229
229
|
inputs: [m.item.file],
|
|
230
230
|
});
|
|
231
|
-
// A move on the item ends the session that worked it: the board stops showing it running
|
|
231
|
+
// A move on the item ends the session that worked it: the board stops showing it running,
|
|
232
|
+
// and no rerun resumes it.
|
|
232
233
|
releaseSession(m.projectDir, String(m.item.data.id));
|
|
234
|
+
dropAgentSession(m.projectDir, String(m.item.data.id));
|
|
233
235
|
|
|
234
236
|
return hash;
|
|
235
237
|
}
|
package/package.json
CHANGED
|
@@ -28,6 +28,7 @@ gcf_forbidden:
|
|
|
28
28
|
- do not give a verdict or a decision: `verdict` and `decide` belong to the human, through the gcf-verdict skill
|
|
29
29
|
- do not answer a `TBD` of a design unit yourself: ask the human and offer your draft as the proposed answer
|
|
30
30
|
- do not run `ai-factory gcf phase next`: the phase moves on the human's word only
|
|
31
|
+
- do not change what agent runs may do: `ai-factory gcf permissions` and its deny groups belong to the human
|
|
31
32
|
- do not reuse an ended id: rework gets a new id with `reopens: <old id>`
|
|
32
33
|
- do not commit past the gate with `--no-verify`: a refused commit names what to fix
|
|
33
34
|
|
package/templates/config.yml
CHANGED
|
@@ -9,6 +9,14 @@ envelope_default_spread: 0.3
|
|
|
9
9
|
unplayed_warn_after: 5
|
|
10
10
|
plan_passes: 5
|
|
11
11
|
dod_attempts: 2
|
|
12
|
+
# Agent runs (Harness-PRD §7): the share of the context window at which a run hands its item
|
|
13
|
+
# to a fresh session (0: never), the window when the model's is unknown (0: the model's), and
|
|
14
|
+
# the agent's auto-compaction share (0: its default; at least the handoff share + 15).
|
|
15
|
+
context_handoff_pct: 60
|
|
16
|
+
context_window: 0
|
|
17
|
+
auto_compact_pct: 0
|
|
18
|
+
# What agent runs may not do lives in an `agent:` section that `ai-factory gcf permissions`
|
|
19
|
+
# and the board write; without it the default deny groups hold.
|
|
12
20
|
# The project's own commands for the auto DoD (Methodology §6); the engine does not matter.
|
|
13
21
|
# gcf verify fails on an empty one; `none` says the project has no such step.
|
|
14
22
|
# Phases (Methodology §10) come from code; a `phases:` list here replaces the whole set.
|