gcf-aif-ext 0.6.0 → 0.7.1
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 +13 -0
- package/commands/gcf.js +67 -0
- package/extension.json +1 -1
- package/lib/audit-core.js +6 -2
- package/lib/board.js +25 -22
- package/lib/design-check.js +8 -2
- package/lib/gcf-paths.js +20 -0
- package/lib/integrate.js +1 -1
- package/lib/questions.js +95 -0
- package/lib/session-mark.js +52 -0
- package/lib/validate-item.js +4 -0
- package/lib/work-ops.js +66 -4
- package/package.json +1 -1
- package/skills/gcf-card/SKILL.md +11 -6
- package/templates/agents-block.md +2 -0
package/README.md
CHANGED
|
@@ -50,6 +50,19 @@ 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.1
|
|
54
|
+
|
|
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.
|
|
56
|
+
|
|
57
|
+
### 0.7.0
|
|
58
|
+
|
|
59
|
+
- `priority:` on an item: a rank, lower runs sooner. `ai-factory gcf prioritize <id> <id> …` ranks items in that order in one commit; `item new --priority <n>`. `gcf board --json` lists ranked items first and carries `priority`.
|
|
60
|
+
- `ai-factory gcf question answer <q-id> --text="<answer>"` writes the human's answer into its record of `gcf/open-questions.md` and commits. An answered D1 no longer blocks its units; the next agent on the unit puts the answer into its slot (`answer: q-NNN`) and removes the record. `gcf board --json` questions carry `question`, `proposed`, `blocks` and `answer`.
|
|
61
|
+
- `ai-factory gcf claim <id>` / `release <id>`: an agent session working an item in the main checkout marks it running. The session's own `context`, `plan check` and `verify` on the item keep the mark fresh (so do a harness run on main and its readset-guard hook), a move on the item clears it, and 2 hours without either expire it. The board and the audit read it as `running`, so a harness run does not take the item, and the core refuses any move on it but the session's own (`submit`, `close`, `block` with `--claimed`).
|
|
62
|
+
- `prioritize` leaves the rank of a ready item it does not name (a running one): the named items take the free ranks around it.
|
|
63
|
+
- `run_mode: worktree | main` in `gcf/config.yml` (Methodology §7): a project whose agent works through an editor bound to the project folder sets `main`. `gcf board --json` carries it.
|
|
64
|
+
- `/gcf-card` cuts several items along subsystem boundaries, marks two items of one subsystem that change different files `parallel: ok`, ranks them with `prioritize`, and keeps `depends` for real needs only.
|
|
65
|
+
|
|
53
66
|
### 0.6.0
|
|
54
67
|
|
|
55
68
|
- `gcf board --json` carries `schema: 1`, the version of its format (Methodology §11 lists every field); a board refuses a version it does not know. New fields: per item `file`, `plan_file`, `log`, `depends`, `milestone`, `tune_count`, `since`, `model`, `effort` and `moves` (what the item takes now by Methodology §4.1, empty while a run drives it); at the top `history` and `graveyard`.
|
package/commands/gcf.js
CHANGED
|
@@ -18,6 +18,40 @@ const out = (json, value, human) =>
|
|
|
18
18
|
|
|
19
19
|
const HARNESS = 'gcf-harness-aif-ext';
|
|
20
20
|
|
|
21
|
+
const CLAIMED = 'the agent session that claimed the item (gcf claim) records its own move';
|
|
22
|
+
|
|
23
|
+
/** `--claimed`: this move comes from the session that holds the item's mark. */
|
|
24
|
+
const claimedBy = (o, id) => {
|
|
25
|
+
if (o.claimed) process.env.GCF_RUN_ITEM = id;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
/** A session's own command on its item keeps the item's running mark fresh. */
|
|
29
|
+
const touchSession = async (id) => (await lib('session-mark')).touchSession(process.cwd(), id);
|
|
30
|
+
|
|
31
|
+
function registerSession(gcf) {
|
|
32
|
+
gcf
|
|
33
|
+
.command('claim <id>')
|
|
34
|
+
.description(
|
|
35
|
+
'An agent session starts working the item on main: the board shows it running until a ' +
|
|
36
|
+
'move ends it, release, or 30 minutes without a gcf command on it',
|
|
37
|
+
)
|
|
38
|
+
.action(async (id) => {
|
|
39
|
+
const { buildLedger } = await lib('ledger');
|
|
40
|
+
if (!(await buildLedger(process.cwd())).item(id)) throw new Error(`${id} is not a work item`);
|
|
41
|
+
await (await lib('session-mark')).claimSession(process.cwd(), id);
|
|
42
|
+
console.log(`claimed ${id}`);
|
|
43
|
+
});
|
|
44
|
+
gcf
|
|
45
|
+
.command('release <id>')
|
|
46
|
+
.description('The session stops working the item without a move: it no longer shows running')
|
|
47
|
+
.action(async (id) => {
|
|
48
|
+
const { isValidId } = await lib('id');
|
|
49
|
+
if (!isValidId(id)) throw new Error(`${id} is not an item id`);
|
|
50
|
+
(await lib('session-mark')).releaseSession(process.cwd(), id);
|
|
51
|
+
console.log(`released ${id}`);
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
|
|
21
55
|
/**
|
|
22
56
|
* `--harness` / `--no-harness` decide. Without either, ask only when someone can answer
|
|
23
57
|
* and the answer matters: an agent, CI or MinTTY run has no terminal, and a project
|
|
@@ -195,6 +229,7 @@ function registerContext(gcf) {
|
|
|
195
229
|
.description('Assemble the readset for a work item (GCF-PRD §9.1)')
|
|
196
230
|
.option('--json', 'machine output')
|
|
197
231
|
.action(async (itemId, opts) => {
|
|
232
|
+
await touchSession(itemId);
|
|
198
233
|
const { buildContext } = await lib('context');
|
|
199
234
|
const ctx = await buildContext(process.cwd(), itemId);
|
|
200
235
|
// Metric 1 (GCF-PRD §14): readset size over time, one csv line per call.
|
|
@@ -644,6 +679,7 @@ function registerItem(gcf) {
|
|
|
644
679
|
.option('--milestone <name>', 'the milestone of game.md it belongs to')
|
|
645
680
|
.option('--release <version>', 'a release: the version it ships')
|
|
646
681
|
.option('--channel <name>', 'a release: where it ships, e.g. steam')
|
|
682
|
+
.option('--priority <rank>', 'its rank: 1, 2, 3… (lower runs sooner)')
|
|
647
683
|
.option('--model <name>', 'the model its run uses, e.g. opus')
|
|
648
684
|
.option('--effort <level>', 'the effort its run uses, e.g. high')
|
|
649
685
|
.option('--commit', 'commit it (gcf(item)): for a caller with no console, e.g. a board')
|
|
@@ -666,6 +702,7 @@ function registerItem(gcf) {
|
|
|
666
702
|
channel: o.channel,
|
|
667
703
|
model: o.model,
|
|
668
704
|
effort: o.effort,
|
|
705
|
+
priority: o.priority === undefined ? undefined : Number(o.priority),
|
|
669
706
|
commit: o.commit === true,
|
|
670
707
|
});
|
|
671
708
|
console.log(`${r.item}${shortHash(r.hash)}`);
|
|
@@ -674,11 +711,34 @@ function registerItem(gcf) {
|
|
|
674
711
|
|
|
675
712
|
function registerItemMoves(gcf) {
|
|
676
713
|
const ops = () => lib('work-ops');
|
|
714
|
+
gcf
|
|
715
|
+
.command('prioritize <ids...>')
|
|
716
|
+
.description('Rank items in this order: priority 1, 2, 3… (lower runs sooner; commit)')
|
|
717
|
+
.option('--no-commit', 'skip the commit (tests only)')
|
|
718
|
+
.action(async (ids, o) => {
|
|
719
|
+
const r = await (await ops()).prioritizeItems(process.cwd(), ids, { commit: o.commit });
|
|
720
|
+
console.log(`ranked ${r.ids.join(', ')}${shortHash(r.hash)}`);
|
|
721
|
+
});
|
|
722
|
+
const question = gcf.command('question').description('Open questions (gcf/open-questions.md)');
|
|
723
|
+
question
|
|
724
|
+
.command('answer <qid>')
|
|
725
|
+
.description('The human answers a question: written into its record, commit gcf(answer)')
|
|
726
|
+
// An answer may start with a hyphen; as `--text=<text>` it stays one value.
|
|
727
|
+
.requiredOption('--text <text>', 'the answer, one line')
|
|
728
|
+
.option('--no-commit', 'skip the commit (tests only)')
|
|
729
|
+
.action(async (qid, o) => {
|
|
730
|
+
const r = await (
|
|
731
|
+
await lib('questions')
|
|
732
|
+
).answerQuestion(process.cwd(), qid, o.text, { commit: o.commit });
|
|
733
|
+
console.log(`answered ${r.id}${shortHash(r.hash)}`);
|
|
734
|
+
});
|
|
677
735
|
gcf
|
|
678
736
|
.command('submit <id>')
|
|
679
737
|
.description('ready → awaiting-verdict / awaiting-decision (commit gcf(submit))')
|
|
738
|
+
.option('--claimed', CLAIMED)
|
|
680
739
|
.option('--no-commit', 'skip the commit (tests only)')
|
|
681
740
|
.action(async (id, o) => {
|
|
741
|
+
claimedBy(o, id);
|
|
682
742
|
const r = await (await ops()).submitItem(process.cwd(), id, { commit: o.commit });
|
|
683
743
|
console.log(`${r.id} → ${r.status}${shortHash(r.hash)}`);
|
|
684
744
|
});
|
|
@@ -691,8 +751,10 @@ function registerItemMoves(gcf) {
|
|
|
691
751
|
(v, p) => [...p, v],
|
|
692
752
|
[],
|
|
693
753
|
)
|
|
754
|
+
.option('--claimed', CLAIMED)
|
|
694
755
|
.option('--no-commit', 'skip the commit (tests only)')
|
|
695
756
|
.action(async (id, o) => {
|
|
757
|
+
claimedBy(o, id);
|
|
696
758
|
const r = await (
|
|
697
759
|
await ops()
|
|
698
760
|
).closeItem(process.cwd(), id, { constraints: o.constraint, commit: o.commit });
|
|
@@ -723,8 +785,10 @@ function registerDecide(gcf) {
|
|
|
723
785
|
.command('block <id>')
|
|
724
786
|
.description('ready → blocked, with the reason or the question (commit gcf(block))')
|
|
725
787
|
.requiredOption('--reason <text>', 'why it cannot go on, or the question for you')
|
|
788
|
+
.option('--claimed', CLAIMED)
|
|
726
789
|
.option('--no-commit', 'skip the commit (tests only)')
|
|
727
790
|
.action(async (id, o) => {
|
|
791
|
+
claimedBy(o, id);
|
|
728
792
|
const r = await (
|
|
729
793
|
await ops()
|
|
730
794
|
).blockItem(process.cwd(), id, { reason: o.reason, commit: o.commit });
|
|
@@ -857,6 +921,7 @@ function registerPlan(gcf) {
|
|
|
857
921
|
.description('Deterministic plan check: it exists, has task checkboxes, readset in budget')
|
|
858
922
|
.option('--json', 'machine output')
|
|
859
923
|
.action(async (id, o) => {
|
|
924
|
+
await touchSession(id);
|
|
860
925
|
const r = await (await lib('plan-check')).planCheck(process.cwd(), id);
|
|
861
926
|
out(o.json, r, (x) =>
|
|
862
927
|
x.ok ? `${x.plan}: ${x.done}/${x.tasks} tasks done` : x.problems.join('\n'),
|
|
@@ -872,6 +937,7 @@ function registerVerify(gcf) {
|
|
|
872
937
|
.option('--mark', 'in its worktree: green and committed → commit the GCF-Verified trailer')
|
|
873
938
|
.option('--json', 'machine output')
|
|
874
939
|
.action(async (id, o) => {
|
|
940
|
+
await touchSession(id);
|
|
875
941
|
const r = o.mark
|
|
876
942
|
? await (await lib('integrate')).markVerified(process.cwd(), id)
|
|
877
943
|
: await (await lib('verify')).verifyItem(process.cwd(), id);
|
|
@@ -1059,6 +1125,7 @@ export function register(program) {
|
|
|
1059
1125
|
registerMoves(gcf);
|
|
1060
1126
|
registerItem(gcf);
|
|
1061
1127
|
registerItemMoves(gcf);
|
|
1128
|
+
registerSession(gcf);
|
|
1062
1129
|
registerDecide(gcf);
|
|
1063
1130
|
registerInbox(gcf);
|
|
1064
1131
|
registerLive(gcf);
|
package/extension.json
CHANGED
package/lib/audit-core.js
CHANGED
|
@@ -10,6 +10,7 @@ import { buildLedger } from './ledger.js';
|
|
|
10
10
|
import { STORED } from './transitions.js';
|
|
11
11
|
import { openDepends } from './work-ops.js';
|
|
12
12
|
import { runHolder } from './main-lock.js';
|
|
13
|
+
import { sessionActive } from './session-mark.js';
|
|
13
14
|
|
|
14
15
|
function git(args, cwd) {
|
|
15
16
|
// A blame of a long file overflows the 1 MiB default, and an overflow reads as a git failure.
|
|
@@ -42,7 +43,7 @@ const escapeRe = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
|
42
43
|
* @property {number} [schema]
|
|
43
44
|
* @property {Array<{id: string, type: string, gate: string, status: string, system: string[],
|
|
44
45
|
* depends: string[], parent: string | null, tune_count: number, parallel: string,
|
|
45
|
-
* running: boolean}>} [items]
|
|
46
|
+
* priority: number | null, running: boolean}>} [items]
|
|
46
47
|
* @property {{blocking: number, units: number, ready: number}} [design]
|
|
47
48
|
*/
|
|
48
49
|
|
|
@@ -96,7 +97,10 @@ export async function audit(projectDir) {
|
|
|
96
97
|
parent: c.data.parent ?? null,
|
|
97
98
|
tune_count: c.data.tune_count ?? 0,
|
|
98
99
|
parallel: c.data.parallel ?? 'serial',
|
|
99
|
-
|
|
100
|
+
priority: c.data.priority ?? null,
|
|
101
|
+
// A harness run's live lock, or an interactive session's fresh mark on main.
|
|
102
|
+
running:
|
|
103
|
+
runHolder(projectDir, c.data.id) !== null || sessionActive(projectDir, String(c.data.id)),
|
|
100
104
|
}));
|
|
101
105
|
const design = await designCheck(projectDir);
|
|
102
106
|
checkItems(report, items, config, { projectDir, ledger });
|
package/lib/board.js
CHANGED
|
@@ -14,6 +14,8 @@ import { wipOf } from './audit-core.js';
|
|
|
14
14
|
import { runHolder } from './main-lock.js';
|
|
15
15
|
import { ITEM_TYPES } from './validate-item.js';
|
|
16
16
|
import { movesOf } from './transitions.js';
|
|
17
|
+
import { listQuestions } from './questions.js';
|
|
18
|
+
import { sessionActive } from './session-mark.js';
|
|
17
19
|
|
|
18
20
|
// The version of the JSON below (Methodology §11): a board refuses one it does not know. Bump it
|
|
19
21
|
// when a field changes meaning or goes away; a new field does not bump it.
|
|
@@ -95,21 +97,6 @@ function verifiedOf(projectDir) {
|
|
|
95
97
|
}
|
|
96
98
|
}
|
|
97
99
|
|
|
98
|
-
/** Open question records of open-questions.md: id and class. */
|
|
99
|
-
async function questions(root) {
|
|
100
|
-
const file = path.join(root, 'open-questions.md');
|
|
101
|
-
if (!existsSync(file)) return [];
|
|
102
|
-
|
|
103
|
-
return (await readFile(file, 'utf8'))
|
|
104
|
-
.replace(/\r\n/g, '\n')
|
|
105
|
-
.split(/\n(?=- id:)/)
|
|
106
|
-
.flatMap((rec) => {
|
|
107
|
-
const id = rec.match(/^- id:\s*(\S+)/)?.[1];
|
|
108
|
-
|
|
109
|
-
return id ? [{ id, class: rec.match(/^\s+class:\s*(\S+)/m)?.[1] ?? null }] : [];
|
|
110
|
-
});
|
|
111
|
-
}
|
|
112
|
-
|
|
113
100
|
/** The phase status, or the reason it cannot be read: the board still shows the rest. */
|
|
114
101
|
async function phaseOf(projectDir) {
|
|
115
102
|
try {
|
|
@@ -134,7 +121,19 @@ function logOf(projectDir, id) {
|
|
|
134
121
|
}
|
|
135
122
|
|
|
136
123
|
// Fields of the item file a board shows as they are: null or [] when the file has none.
|
|
137
|
-
const SCALARS = [
|
|
124
|
+
const SCALARS = [
|
|
125
|
+
'title',
|
|
126
|
+
'parent',
|
|
127
|
+
'milestone',
|
|
128
|
+
'priority',
|
|
129
|
+
'tune_count',
|
|
130
|
+
'since',
|
|
131
|
+
'model',
|
|
132
|
+
'effort',
|
|
133
|
+
];
|
|
134
|
+
|
|
135
|
+
// Ranked items first, lower rank sooner; the unranked keep their file order after them.
|
|
136
|
+
const byPriority = (a, b) => (a.priority ?? Infinity) - (b.priority ?? Infinity);
|
|
138
137
|
const LISTS = ['children', 'system', 'depends'];
|
|
139
138
|
|
|
140
139
|
/** One item of the board. */
|
|
@@ -142,11 +141,12 @@ async function itemOf(projectDir, c, verified) {
|
|
|
142
141
|
const id = String(c.data.id);
|
|
143
142
|
// A trailer counts only while the item waits on main for its integrate.
|
|
144
143
|
const ready = c.data.status === 'ready' && verified.has(id);
|
|
145
|
-
// `running` and `verify` are not stored (§4): a live harness run
|
|
146
|
-
//
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
144
|
+
// `running` and `verify` are not stored (§4): a live harness run lock or a fresh session mark
|
|
145
|
+
// is a run; a trailer is a passed DoD. A worktree with neither is a run that stopped (a
|
|
146
|
+
// timeout, a stop, a crash): the item is ready, and its next run resumes that worktree.
|
|
147
|
+
const running = runHolder(projectDir, id) !== null || sessionActive(projectDir, id);
|
|
148
|
+
const resumable =
|
|
149
|
+
!running && c.data.status === 'ready' && !ready && existsSync(worktreeOf(projectDir, id));
|
|
150
150
|
|
|
151
151
|
return {
|
|
152
152
|
id,
|
|
@@ -158,6 +158,7 @@ async function itemOf(projectDir, c, verified) {
|
|
|
158
158
|
...(c.data.severity ? { severity: c.data.severity } : {}),
|
|
159
159
|
...(c.data.blocked ? { blocked: c.data.blocked } : {}),
|
|
160
160
|
running,
|
|
161
|
+
resumable,
|
|
161
162
|
verified: ready,
|
|
162
163
|
// What a board may offer (§4.1): nothing while a run drives the item.
|
|
163
164
|
moves: running ? [] : movesOf(c.data),
|
|
@@ -173,12 +174,14 @@ export async function board(projectDir) {
|
|
|
173
174
|
const verified = verifiedOf(projectDir);
|
|
174
175
|
const items = [];
|
|
175
176
|
for (const c of ledger.items) items.push(await itemOf(projectDir, c, verified));
|
|
177
|
+
items.sort(byPriority);
|
|
176
178
|
|
|
177
179
|
const config = await readConfig(root);
|
|
178
180
|
const live = await readLive(root);
|
|
179
181
|
|
|
180
182
|
return {
|
|
181
183
|
schema: BOARD_SCHEMA,
|
|
184
|
+
run_mode: config.run_mode,
|
|
182
185
|
phase: await phaseOf(projectDir),
|
|
183
186
|
// A malformed block reads with fields missing (live check names it): null, not absent.
|
|
184
187
|
release: releaseOf(live.release),
|
|
@@ -188,7 +191,7 @@ export async function board(projectDir) {
|
|
|
188
191
|
items,
|
|
189
192
|
wip: wipOf(ledger.items, config),
|
|
190
193
|
inbox: await listInbox(root),
|
|
191
|
-
questions: await
|
|
194
|
+
questions: await listQuestions(root),
|
|
192
195
|
history: await historyOf(root),
|
|
193
196
|
graveyard: await graveyardOf(root),
|
|
194
197
|
};
|
package/lib/design-check.js
CHANGED
|
@@ -121,7 +121,13 @@ function itemsIn(text, key) {
|
|
|
121
121
|
export const recordsIn = (text, key) =>
|
|
122
122
|
readRecords(itemsIn(text, key)).records.map((r) => r.values);
|
|
123
123
|
|
|
124
|
-
/**
|
|
124
|
+
/** A D1 record nobody answered yet: only such a question holds its units. */
|
|
125
|
+
const blocksUnits = (rec) => /^\s+class:\s*D1\b/m.test(rec) && !/^\s+answer:/m.test(rec);
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* open-questions.md: every record id, every `blocks:` reference, the D1 ones per blocked id. A
|
|
129
|
+
* D1 the human answered (`answer:`) blocks no more: the next agent on the unit applies it.
|
|
130
|
+
*/
|
|
125
131
|
export function questionBlocks(text) {
|
|
126
132
|
/** @type {Map<string, string[]>} */
|
|
127
133
|
const d1 = new Map();
|
|
@@ -133,7 +139,7 @@ export function questionBlocks(text) {
|
|
|
133
139
|
const ids = listOf(rec.match(/^\s+blocks:\s*(.+)$/m)?.[1] ?? '');
|
|
134
140
|
if (!qid || ids === 'TBD' || !ids.length) continue;
|
|
135
141
|
refs.push({ qid, ids });
|
|
136
|
-
if (
|
|
142
|
+
if (!blocksUnits(rec)) continue;
|
|
137
143
|
for (const id of ids) d1.set(id, [...(d1.get(id) ?? []), qid]);
|
|
138
144
|
}
|
|
139
145
|
|
package/lib/gcf-paths.js
CHANGED
|
@@ -26,7 +26,27 @@ export function gcfRoot(projectDir = process.cwd()) {
|
|
|
26
26
|
return root;
|
|
27
27
|
}
|
|
28
28
|
|
|
29
|
+
// How a harness run drives an item (Methodology §7): `worktree` — each in its own copy, several
|
|
30
|
+
// at once; `main` — one at a time in the project's own folder, for an agent that works through
|
|
31
|
+
// a running editor bound to that folder. The project chooses; the framework knows no engine.
|
|
32
|
+
export const RUN_MODES = ['worktree', 'main'];
|
|
33
|
+
|
|
34
|
+
function runModeOf(v = 'worktree') {
|
|
35
|
+
if (!RUN_MODES.includes(v))
|
|
36
|
+
throw new Error(`gcf/config.yml: run_mode must be ${RUN_MODES.join(' | ')}, got ${v}`);
|
|
37
|
+
|
|
38
|
+
return v;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** @returns {Promise<Record<string, any>>} */
|
|
29
42
|
export async function readConfig(root) {
|
|
43
|
+
const config = await readNumbers(root);
|
|
44
|
+
|
|
45
|
+
return { ...config, run_mode: runModeOf(config.run_mode) };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** @returns {Promise<Record<string, any>>} */
|
|
49
|
+
async function readNumbers(root) {
|
|
30
50
|
const file = path.join(root, 'config.yml');
|
|
31
51
|
if (!existsSync(file)) return { ...DEFAULT_CONFIG };
|
|
32
52
|
const parsed = YAML.parse(await readFile(file, 'utf8')) ?? {};
|
package/lib/integrate.js
CHANGED
|
@@ -27,7 +27,7 @@ function git(cwd, args, { check = true } = {}) {
|
|
|
27
27
|
}
|
|
28
28
|
|
|
29
29
|
/** `.gcf-session/` stays out of `git status` without a tracked .gitignore change. */
|
|
30
|
-
async function excludeSession(projectDir) {
|
|
30
|
+
export async function excludeSession(projectDir) {
|
|
31
31
|
const common = path.resolve(projectDir, git(projectDir, ['rev-parse', '--git-common-dir']).out);
|
|
32
32
|
const file = path.join(common, 'info', 'exclude');
|
|
33
33
|
const text = existsSync(file) ? await readFile(file, 'utf8') : '';
|
package/lib/questions.js
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
// gcf/open-questions.md (GCF-PRD §11.8): the questions only the human answers (D1) and the
|
|
2
|
+
// agent's own assumptions (D2). An answer is written into the record itself: an answered D1
|
|
3
|
+
// blocks no unit, and the next agent on the unit puts the answer into its slot (provenance
|
|
4
|
+
// `answer: q-NNN`) and removes the record.
|
|
5
|
+
import { readFile } from 'node:fs/promises';
|
|
6
|
+
import { existsSync } from 'node:fs';
|
|
7
|
+
import path from 'node:path';
|
|
8
|
+
import { atomicWrite } from './safe-path.js';
|
|
9
|
+
import { commitMove } from './unit-ops.js';
|
|
10
|
+
|
|
11
|
+
const FIELDS = ['class', 'question', 'proposed', 'blocks', 'answer'];
|
|
12
|
+
|
|
13
|
+
const fileOf = (root) => path.join(root, 'open-questions.md');
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* The records of a file's text, each `{id, text, start}`: a `- id:` line and the indented lines
|
|
17
|
+
* under it. A blank or unindented line ends the record: a heading after the last record is not
|
|
18
|
+
* part of it.
|
|
19
|
+
*/
|
|
20
|
+
function recordsOf(text) {
|
|
21
|
+
const records = [];
|
|
22
|
+
let at = 0;
|
|
23
|
+
/** @type {{id: string, text: string, start: number} | null} */
|
|
24
|
+
let rec = null;
|
|
25
|
+
for (const line of text.split(/(?<=\n)/)) {
|
|
26
|
+
const id = line.match(/^- id:\s*(\S+)/)?.[1];
|
|
27
|
+
if (id) records.push((rec = { id, text: line, start: at }));
|
|
28
|
+
else if (rec && /^[ \t]+\S/.test(line)) rec.text += line;
|
|
29
|
+
else rec = null;
|
|
30
|
+
at += line.length;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
return records;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Every question: id, class, the question, the proposed answer, the ids it blocks and the
|
|
38
|
+
* human's answer, each null when the record has none.
|
|
39
|
+
*/
|
|
40
|
+
export async function listQuestions(root) {
|
|
41
|
+
const file = fileOf(root);
|
|
42
|
+
if (!existsSync(file)) return [];
|
|
43
|
+
|
|
44
|
+
return recordsOf(await readFile(file, 'utf8')).map(({ id, text }) => ({
|
|
45
|
+
id,
|
|
46
|
+
...Object.fromEntries(
|
|
47
|
+
FIELDS.map((k) => [
|
|
48
|
+
k,
|
|
49
|
+
text.match(new RegExp(`^[ \\t]+${k}:[ \\t]*(.*?)\\s*$`, 'm'))?.[1] || null,
|
|
50
|
+
]),
|
|
51
|
+
),
|
|
52
|
+
}));
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** The record text with its `answer:` set: an old answer is replaced, a new one appended. */
|
|
56
|
+
function withAnswer(record, answer) {
|
|
57
|
+
const eol = record.includes('\r\n') ? '\r\n' : '\n';
|
|
58
|
+
const indent = record.match(/^([ \t]+)\w+:/m)?.[1] || ' ';
|
|
59
|
+
const line = `${indent}answer: ${answer}`;
|
|
60
|
+
// A function, not a string: `$&` in the operator's answer is text, not a pattern.
|
|
61
|
+
if (/^[ \t]+answer:/m.test(record))
|
|
62
|
+
return record.replace(/^[ \t]+answer:.*?(?=\r?$)/m, () => line);
|
|
63
|
+
const body = record.replace(/(\r?\n)+$/, '');
|
|
64
|
+
|
|
65
|
+
return `${body}${eol}${line}${record.slice(body.length) || eol}`;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* The human answers question `qid` (`gcf question answer`): the answer goes into its record, one
|
|
70
|
+
* line, and commits as `gcf(answer): <qid>`. Refuses an id the file does not hold.
|
|
71
|
+
*/
|
|
72
|
+
export async function answerQuestion(projectDir, qid, answer, { commit = true } = {}) {
|
|
73
|
+
const text = String(answer ?? '')
|
|
74
|
+
.replace(/\s+/g, ' ')
|
|
75
|
+
.trim();
|
|
76
|
+
if (!text) throw new Error('an answer needs text');
|
|
77
|
+
const file = fileOf(path.join(path.resolve(projectDir), 'gcf'));
|
|
78
|
+
const old = existsSync(file) ? await readFile(file, 'utf8') : '';
|
|
79
|
+
const rec = recordsOf(old).find((r) => r.id === qid);
|
|
80
|
+
if (!rec) throw new Error(`${qid} is not a record of gcf/open-questions.md`);
|
|
81
|
+
|
|
82
|
+
const hash = await commitMove(projectDir, `gcf(answer): ${qid}`, {
|
|
83
|
+
files: [file],
|
|
84
|
+
mutate: () =>
|
|
85
|
+
atomicWrite(
|
|
86
|
+
file,
|
|
87
|
+
old.slice(0, rec.start) +
|
|
88
|
+
withAnswer(rec.text, text) +
|
|
89
|
+
old.slice(rec.start + rec.text.length),
|
|
90
|
+
),
|
|
91
|
+
commit,
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
return { id: qid, answer: text, hash };
|
|
95
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
// An agent session working an item in the main checkout (Methodology §4: `running`). A harness
|
|
2
|
+
// run holds a lock with its pid; an interactive session has no process the core can see, so
|
|
3
|
+
// `/gcf-run` claims the item with a mark, the session's own gcf commands on the item keep it
|
|
4
|
+
// fresh (and so do a harness run on main and the harness's readset-guard hook on each read),
|
|
5
|
+
// and the move that ends the run clears it. A mark nobody refreshed for SESSION_TTL_MS is a
|
|
6
|
+
// session that stopped: the item reads as not running again.
|
|
7
|
+
import { existsSync, rmSync, statSync, utimesSync } from 'node:fs';
|
|
8
|
+
import { mkdir, writeFile } from 'node:fs/promises';
|
|
9
|
+
import path from 'node:path';
|
|
10
|
+
import { excludeSession } from './integrate.js';
|
|
11
|
+
|
|
12
|
+
// A run's default timeout: an agent can implement for long without a gcf command. The harness
|
|
13
|
+
// and its readset-guard hook refresh a live session's mark meanwhile.
|
|
14
|
+
export const SESSION_TTL_MS = 120 * 60_000;
|
|
15
|
+
|
|
16
|
+
const markOf = (projectDir, id) =>
|
|
17
|
+
path.join(path.resolve(projectDir), '.gcf-session', 'runs', `${id}.session`);
|
|
18
|
+
|
|
19
|
+
/** Mark item `id` as worked by this session. */
|
|
20
|
+
export async function claimSession(projectDir, id) {
|
|
21
|
+
const file = markOf(projectDir, id);
|
|
22
|
+
await excludeSession(projectDir);
|
|
23
|
+
await mkdir(path.dirname(file), { recursive: true });
|
|
24
|
+
await writeFile(file, `${new Date().toISOString()}\n`);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Keep a session's mark fresh: its own commands on the item call this; no mark, no-op. */
|
|
28
|
+
export function touchSession(projectDir, id) {
|
|
29
|
+
const file = markOf(projectDir, id);
|
|
30
|
+
try {
|
|
31
|
+
const now = new Date();
|
|
32
|
+
utimesSync(file, now, now);
|
|
33
|
+
} catch {
|
|
34
|
+
// No mark: no session claimed the item.
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Clear the mark: the session ended its run, or gave up. */
|
|
39
|
+
export function releaseSession(projectDir, id) {
|
|
40
|
+
rmSync(markOf(projectDir, id), { force: true });
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Whether a session works the item now: a mark refreshed within SESSION_TTL_MS. */
|
|
44
|
+
export function sessionActive(projectDir, id) {
|
|
45
|
+
const file = markOf(projectDir, id);
|
|
46
|
+
if (!existsSync(file)) return false;
|
|
47
|
+
try {
|
|
48
|
+
return Date.now() - statSync(file).mtimeMs < SESSION_TTL_MS;
|
|
49
|
+
} catch {
|
|
50
|
+
return false;
|
|
51
|
+
}
|
|
52
|
+
}
|
package/lib/validate-item.js
CHANGED
|
@@ -56,6 +56,7 @@ const FIELDS = [
|
|
|
56
56
|
'reopens',
|
|
57
57
|
'milestone',
|
|
58
58
|
'parallel',
|
|
59
|
+
'priority',
|
|
59
60
|
'plan',
|
|
60
61
|
'tune_count',
|
|
61
62
|
'severity',
|
|
@@ -146,6 +147,9 @@ const RULES = {
|
|
|
146
147
|
reopens: (v) => nullable(isSlug)(v) || 'reopens must name the id it reworks, or null',
|
|
147
148
|
milestone: (v) => nullable(isSlug)(v) || 'milestone must be a milestone name or null',
|
|
148
149
|
parallel: (v) => ['ok', 'serial'].includes(v) || 'parallel must be ok or serial',
|
|
150
|
+
priority: (v) =>
|
|
151
|
+
nullable((x) => Number.isInteger(x) && x >= 1)(v) ||
|
|
152
|
+
'priority must be a rank, an integer >= 1 (lower runs sooner), or null',
|
|
149
153
|
plan: (v) => nullable(isText)(v) || 'plan must be a path or null',
|
|
150
154
|
tune_count: (v) => (Number.isInteger(v) && v >= 0) || 'tune_count must be an integer >= 0',
|
|
151
155
|
severity: (v) =>
|
package/lib/work-ops.js
CHANGED
|
@@ -38,6 +38,7 @@ import {
|
|
|
38
38
|
import { dropInbox } from './inbox.js';
|
|
39
39
|
import { refuseByPhase } from './phase.js';
|
|
40
40
|
import { runHolder } from './main-lock.js';
|
|
41
|
+
import { releaseSession, sessionActive } from './session-mark.js';
|
|
41
42
|
|
|
42
43
|
/**
|
|
43
44
|
* Options of a committed move; each move reads the ones it needs.
|
|
@@ -173,6 +174,13 @@ async function begin(projectDir, id, move) {
|
|
|
173
174
|
`${id} is driven by a harness run (pid ${run}): wait until that run ends; if no harness ` +
|
|
174
175
|
`run is going, delete .gcf-session/runs/${id}.lock`,
|
|
175
176
|
);
|
|
177
|
+
// The session that claimed the item moves it itself (GCF_RUN_ITEM, set by `--claimed` or by
|
|
178
|
+
// a harness run on main); anyone else waits, as for a run lock.
|
|
179
|
+
if (sessionActive(projectDir, id) && process.env.GCF_RUN_ITEM !== id)
|
|
180
|
+
throw new Error(
|
|
181
|
+
`${id} is worked by an agent session (gcf claim): wait until it moves the item, or run ` +
|
|
182
|
+
`ai-factory gcf release ${id} when no session works it`,
|
|
183
|
+
);
|
|
176
184
|
const files = [item.file];
|
|
177
185
|
const steps = [];
|
|
178
186
|
const liveOps = [];
|
|
@@ -202,13 +210,18 @@ async function begin(projectDir, id, move) {
|
|
|
202
210
|
}
|
|
203
211
|
|
|
204
212
|
/** Commit the move as `gcf(<move>): <tail>` with every file it touched (item edits included). */
|
|
205
|
-
|
|
206
|
-
commitMove(m.projectDir, `gcf(${move}): ${tail}`, {
|
|
213
|
+
async function finish(m, move, tail, commit) {
|
|
214
|
+
const hash = await commitMove(m.projectDir, `gcf(${move}): ${tail}`, {
|
|
207
215
|
files: m.files,
|
|
208
216
|
mutate: m.run,
|
|
209
217
|
commit,
|
|
210
218
|
inputs: [m.item.file],
|
|
211
219
|
});
|
|
220
|
+
// A move on the item ends the session that worked it: the board stops showing it running.
|
|
221
|
+
releaseSession(m.projectDir, String(m.item.data.id));
|
|
222
|
+
|
|
223
|
+
return hash;
|
|
224
|
+
}
|
|
212
225
|
|
|
213
226
|
const writeItem = (item, data, body = item.body) => atomicWrite(item.file, serialize(data, body));
|
|
214
227
|
|
|
@@ -742,7 +755,10 @@ function checkRelease(spec) {
|
|
|
742
755
|
|
|
743
756
|
const RUN_KEYS = ['model', 'effort'];
|
|
744
757
|
|
|
745
|
-
/**
|
|
758
|
+
/**
|
|
759
|
+
* How a new item runs: its model and effort (the run passes them to the agent's command line)
|
|
760
|
+
* and its rank among the items.
|
|
761
|
+
*/
|
|
746
762
|
function runFields(spec) {
|
|
747
763
|
for (const k of RUN_KEYS)
|
|
748
764
|
if (spec[k] !== undefined && !RUN_WORD.test(spec[k]))
|
|
@@ -750,8 +766,13 @@ function runFields(spec) {
|
|
|
750
766
|
`--${k} is one word that starts with a letter or a digit ` +
|
|
751
767
|
'(then letters, digits, dots, hyphens, underscores), e.g. opus',
|
|
752
768
|
);
|
|
769
|
+
const { priority } = spec;
|
|
770
|
+
if (priority !== undefined && !(Number.isInteger(priority) && priority >= 1))
|
|
771
|
+
throw new Error('--priority is a rank: an integer from 1 (lower runs sooner)');
|
|
753
772
|
|
|
754
|
-
return Object.fromEntries(
|
|
773
|
+
return Object.fromEntries(
|
|
774
|
+
[...RUN_KEYS, 'priority'].filter((k) => spec[k] !== undefined).map((k) => [k, spec[k]]),
|
|
775
|
+
);
|
|
755
776
|
}
|
|
756
777
|
|
|
757
778
|
/** Refuse a spec `item new` cannot honour; returns the gate the item gets. */
|
|
@@ -830,6 +851,47 @@ function newItemText(spec, gate, since) {
|
|
|
830
851
|
return serialize(data, body);
|
|
831
852
|
}
|
|
832
853
|
|
|
854
|
+
/**
|
|
855
|
+
* Rank items in the given order: `priority` 1, 2, 3… (lower runs sooner). One commit
|
|
856
|
+
* `gcf(priority)`. A board sends a column's new order after a drag; the console names the ids.
|
|
857
|
+
*/
|
|
858
|
+
export async function prioritizeItems(projectDir, ids, { commit = true } = {}) {
|
|
859
|
+
if (!ids.length) throw new Error('name the items in the order they should run');
|
|
860
|
+
if (new Set(ids).size !== ids.length) throw new Error('an id is named twice');
|
|
861
|
+
const ledger = await buildLedger(projectDir);
|
|
862
|
+
const items = ids.map((id) => {
|
|
863
|
+
const item = ledger.item(id);
|
|
864
|
+
if (!item) throw new Error(`${id} is not a work item`);
|
|
865
|
+
|
|
866
|
+
return item;
|
|
867
|
+
});
|
|
868
|
+
// A ranked ready item left out of the list (a running one, whose file its run may be editing)
|
|
869
|
+
// keeps its rank: the listed items take the free ranks around it, so no rank is held twice.
|
|
870
|
+
const held = new Set(
|
|
871
|
+
ledger.items
|
|
872
|
+
.filter((i) => i.data.status === 'ready' && !ids.includes(String(i.data.id)))
|
|
873
|
+
.map((i) => i.data.priority)
|
|
874
|
+
.filter(Boolean),
|
|
875
|
+
);
|
|
876
|
+
let rank = 0;
|
|
877
|
+
const ranks = ids.map(() => {
|
|
878
|
+
do rank++;
|
|
879
|
+
while (held.has(rank));
|
|
880
|
+
|
|
881
|
+
return rank;
|
|
882
|
+
});
|
|
883
|
+
const hash = await commitMove(projectDir, `gcf(priority): ${ids.join(', ')}`, {
|
|
884
|
+
files: items.map((i) => i.file),
|
|
885
|
+
mutate: async () => {
|
|
886
|
+
for (const [n, i] of items.entries())
|
|
887
|
+
await atomicWrite(i.file, serialize({ ...i.data, priority: ranks[n] }, i.body));
|
|
888
|
+
},
|
|
889
|
+
commit,
|
|
890
|
+
});
|
|
891
|
+
|
|
892
|
+
return { ids, hash };
|
|
893
|
+
}
|
|
894
|
+
|
|
833
895
|
/**
|
|
834
896
|
* A new work item in ready (or blocked), no commit. A design unit enters work through `take`,
|
|
835
897
|
* not here: its body is the unit's slots. `from` erases the inbox entry it came from.
|
package/package.json
CHANGED
package/skills/gcf-card/SKILL.md
CHANGED
|
@@ -18,11 +18,12 @@ preconditions:
|
|
|
18
18
|
forbidden_behaviors:
|
|
19
19
|
- do not invent a new subsystem name when INDEX carries a synonym: reuse or ask
|
|
20
20
|
- do not answer a `TBD` yourself: ask the human, and offer your draft as the proposed answer
|
|
21
|
-
- do not take a unit an open D1 blocks: `ai-factory gcf design check` names the blocking question, and the human answers it first
|
|
21
|
+
- do not take a unit an open D1 blocks: `ai-factory gcf design check` names the blocking question, and the human answers it first (`ai-factory gcf question answer <q-id> --text="<answer>"`, or on the board)
|
|
22
22
|
- do not write a digit in prose: a number is an envelope `a..b <unit>` in `feel` or a closing comparison
|
|
23
23
|
- do not put `until:` in the frontmatter: it is a line annotation inside slots
|
|
24
24
|
- do not move a section, edit the backlog or write `status:` by hand: `ai-factory gcf take`, `item new`, `done`, `drop` and the item moves own those
|
|
25
25
|
- do not reuse an id: an ended id stays ended; rework gets a new id with `reopens: <old id>`
|
|
26
|
+
- do not put an id in `depends` for order alone: `depends` names an item whose data or code this one uses, and order goes in `priority`
|
|
26
27
|
- do not remove a D2 record cited as `assumed: q-NNN`: it stays in `gcf/open-questions.md` until its `until:` fires; when the human confirms the value earlier, rewrite each citation to `answer: q-NNN` first
|
|
27
28
|
|
|
28
29
|
outputs:
|
|
@@ -43,11 +44,15 @@ workflow:
|
|
|
43
44
|
10. for `pillars: TBD` propose the ids from `gcf/PILLARS.md` whose statement the unit serves and whose invariant it keeps
|
|
44
45
|
11. when no pillar fits, ask whether the unit is out of scope or a pillar is missing, and write a D1 question for the answer
|
|
45
46
|
12. write each answer into the unit in place, with its `answer:` provenance record
|
|
46
|
-
13.
|
|
47
|
-
14. run `ai-factory gcf
|
|
48
|
-
15.
|
|
49
|
-
16.
|
|
50
|
-
17.
|
|
47
|
+
13. a record of `gcf/open-questions.md` that carries the human's `answer:` and names this unit in `blocks:` goes into its slot with `answer: q-NNN`; the record then leaves the file
|
|
48
|
+
14. run `ai-factory gcf design check --json` until the unit's entry in `units` reads `ready: true`, then commit the answered unit
|
|
49
|
+
15. run `ai-factory gcf take <id>` for one unit, or `ai-factory gcf take <id> <id> --feature <feature-id>` to group several under a feature; it writes `gcf/work/<id>.md` and removes the sections and backlog lines in one step
|
|
50
|
+
16. in each mechanic or resource item write `contract:` from the code tree and `interactions:` from the units and items that write the same state; a soft unit whose human wants no playtest gets `gate: auto` - it will close as unplayed
|
|
51
|
+
17. write the item's `live:` delta (a feature's children keep `none: parent`; the feature writes the capability), run `ai-factory gcf design check`, and commit
|
|
52
|
+
18. when the work is several items (a feature's children, or a batch), cut them along subsystem boundaries: each a thin slice that builds and tests alone
|
|
53
|
+
19. two items of one subsystem that change different files get `parallel: ok`, and their `contract:` or `intent:` names those files
|
|
54
|
+
20. rank the items with `ai-factory gcf prioritize <id> <id> …`: dependencies first, then by value
|
|
55
|
+
21. report the item paths, each item's gate, and for a verdict item the one question the playtest will answer
|
|
51
56
|
|
|
52
57
|
invocation:
|
|
53
58
|
- run when work starts on a unit or a group of units, when non-design work needs an item, or when a fresh idea needs a home
|
|
@@ -19,6 +19,8 @@ gcf_work:
|
|
|
19
19
|
- a release item carries `version:` and `channel:` (`item new <id> --type release --release <v> --channel <c>`): its end writes the `release:` block of `gcf/LIVE.md`
|
|
20
20
|
- `item new` and `take` refuse a type the current phase does not take: run `ai-factory gcf phase` and bring the refusal to the human
|
|
21
21
|
- an item the human wants run on a given model or effort gets `item new <id> --model <m> --effort <e>`: the harness run passes both to the agent
|
|
22
|
+
- ready items run in `priority:` order, lower first: rank a set of items with `ai-factory gcf prioritize <id> <id> …`, and put an id in `depends` only when the item uses that one's data or code
|
|
23
|
+
- a D1 record that carries the human's `answer:` goes into the slot of each unit it blocks, with `answer: q-NNN`, and then leaves `gcf/open-questions.md`
|
|
22
24
|
- a pillar changes only on the human's word: `ai-factory gcf pillar rename <from> <to>`, or `ai-factory gcf pillar retire <id> --reason "<why>"`
|
|
23
25
|
|
|
24
26
|
gcf_forbidden:
|