gcf-aif-ext 0.5.3 → 0.7.0
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 +17 -0
- package/commands/gcf.js +129 -12
- package/extension.json +1 -1
- package/lib/audit-core.js +6 -2
- package/lib/board.js +130 -48
- package/lib/design-check.js +8 -2
- package/lib/gcf-paths.js +20 -0
- package/lib/integrate.js +4 -4
- package/lib/live.js +6 -2
- package/lib/playtest-list.js +1 -1
- package/lib/questions.js +95 -0
- package/lib/session-mark.js +52 -0
- package/lib/transitions.js +25 -0
- package/lib/validate-item.js +4 -0
- package/lib/work-ops.js +96 -15
- package/package.json +1 -1
- package/skills/gcf-card/SKILL.md +11 -6
- package/templates/agents-block.md +4 -1
package/README.md
CHANGED
|
@@ -50,6 +50,23 @@ 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.0
|
|
54
|
+
|
|
55
|
+
- `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`.
|
|
56
|
+
- `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`.
|
|
57
|
+
- `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`).
|
|
58
|
+
- `prioritize` leaves the rank of a ready item it does not name (a running one): the named items take the free ranks around it.
|
|
59
|
+
- `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.
|
|
60
|
+
- `/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.
|
|
61
|
+
|
|
62
|
+
### 0.6.0
|
|
63
|
+
|
|
64
|
+
- `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`.
|
|
65
|
+
- `item new --model <m> --effort <e>`: the run of the item uses them (gcf-harness-aif-ext 0.7). One word each.
|
|
66
|
+
- `playtest session --json` and `live check --json` answer JSON: the flag went to the parent command, which printed text.
|
|
67
|
+
- `--commit` on `item new`, `inbox add` and `inbox drop` records the move alone (`gcf(item): <id>`, `gcf(inbox): …`) for a caller with no console, a board. Without it nothing is committed, as before. Inbox lines added earlier without a commit go into the same commit: the inbox is raw input.
|
|
68
|
+
- `inbox add --text=<text>`: a text that starts with a hyphen ("-5 fps on level 3") reads as a flag at every level of ai-factory's commands, `--` included.
|
|
69
|
+
|
|
53
70
|
### 0.5.3
|
|
54
71
|
|
|
55
72
|
- `gcf init` restamps an unplayed item frozen by 0.4.0 (the hash of its body alone), so its `live:` is guarded too — only while that `live:` is still the one its close committed. Commit the restamped items it names.
|
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.
|
|
@@ -321,7 +356,8 @@ function registerPlaytest(gcf) {
|
|
|
321
356
|
const r = await (
|
|
322
357
|
await lib('playtest-list')
|
|
323
358
|
).playtestSession(process.cwd(), { build: Boolean(o.build) });
|
|
324
|
-
|
|
359
|
+
// Commander gives a flag both levels define to the parent: `playtest session --json`.
|
|
360
|
+
out(o.json || playtest.opts().json, r, sessionText);
|
|
325
361
|
if (r.build && !r.build.ok) process.exitCode = 1;
|
|
326
362
|
});
|
|
327
363
|
}
|
|
@@ -643,6 +679,10 @@ function registerItem(gcf) {
|
|
|
643
679
|
.option('--milestone <name>', 'the milestone of game.md it belongs to')
|
|
644
680
|
.option('--release <version>', 'a release: the version it ships')
|
|
645
681
|
.option('--channel <name>', 'a release: where it ships, e.g. steam')
|
|
682
|
+
.option('--priority <rank>', 'its rank: 1, 2, 3… (lower runs sooner)')
|
|
683
|
+
.option('--model <name>', 'the model its run uses, e.g. opus')
|
|
684
|
+
.option('--effort <level>', 'the effort its run uses, e.g. high')
|
|
685
|
+
.option('--commit', 'commit it (gcf(item)): for a caller with no console, e.g. a board')
|
|
646
686
|
.action(async (id, o) => {
|
|
647
687
|
const { newItem } = await lib('work-ops');
|
|
648
688
|
const r = await newItem(process.cwd(), {
|
|
@@ -660,18 +700,45 @@ function registerItem(gcf) {
|
|
|
660
700
|
milestone: o.milestone,
|
|
661
701
|
version: o.release,
|
|
662
702
|
channel: o.channel,
|
|
703
|
+
model: o.model,
|
|
704
|
+
effort: o.effort,
|
|
705
|
+
priority: o.priority === undefined ? undefined : Number(o.priority),
|
|
706
|
+
commit: o.commit === true,
|
|
663
707
|
});
|
|
664
|
-
console.log(r.item);
|
|
708
|
+
console.log(`${r.item}${shortHash(r.hash)}`);
|
|
665
709
|
});
|
|
666
710
|
}
|
|
667
711
|
|
|
668
712
|
function registerItemMoves(gcf) {
|
|
669
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
|
+
});
|
|
670
735
|
gcf
|
|
671
736
|
.command('submit <id>')
|
|
672
737
|
.description('ready → awaiting-verdict / awaiting-decision (commit gcf(submit))')
|
|
738
|
+
.option('--claimed', CLAIMED)
|
|
673
739
|
.option('--no-commit', 'skip the commit (tests only)')
|
|
674
740
|
.action(async (id, o) => {
|
|
741
|
+
claimedBy(o, id);
|
|
675
742
|
const r = await (await ops()).submitItem(process.cwd(), id, { commit: o.commit });
|
|
676
743
|
console.log(`${r.id} → ${r.status}${shortHash(r.hash)}`);
|
|
677
744
|
});
|
|
@@ -684,8 +751,10 @@ function registerItemMoves(gcf) {
|
|
|
684
751
|
(v, p) => [...p, v],
|
|
685
752
|
[],
|
|
686
753
|
)
|
|
754
|
+
.option('--claimed', CLAIMED)
|
|
687
755
|
.option('--no-commit', 'skip the commit (tests only)')
|
|
688
756
|
.action(async (id, o) => {
|
|
757
|
+
claimedBy(o, id);
|
|
689
758
|
const r = await (
|
|
690
759
|
await ops()
|
|
691
760
|
).closeItem(process.cwd(), id, { constraints: o.constraint, commit: o.commit });
|
|
@@ -716,8 +785,10 @@ function registerDecide(gcf) {
|
|
|
716
785
|
.command('block <id>')
|
|
717
786
|
.description('ready → blocked, with the reason or the question (commit gcf(block))')
|
|
718
787
|
.requiredOption('--reason <text>', 'why it cannot go on, or the question for you')
|
|
788
|
+
.option('--claimed', CLAIMED)
|
|
719
789
|
.option('--no-commit', 'skip the commit (tests only)')
|
|
720
790
|
.action(async (id, o) => {
|
|
791
|
+
claimedBy(o, id);
|
|
721
792
|
const r = await (
|
|
722
793
|
await ops()
|
|
723
794
|
).blockItem(process.cwd(), id, { reason: o.reason, commit: o.commit });
|
|
@@ -736,6 +807,36 @@ function registerDecide(gcf) {
|
|
|
736
807
|
});
|
|
737
808
|
}
|
|
738
809
|
|
|
810
|
+
/**
|
|
811
|
+
* An inbox edit; with `commit`, committed alone under the main lock — a board has no console to
|
|
812
|
+
* commit from. Returns the commit hash, or null.
|
|
813
|
+
* @param {boolean | undefined} commit
|
|
814
|
+
* @param {string} message
|
|
815
|
+
* @param {(root: string) => Promise<unknown>} edit
|
|
816
|
+
*/
|
|
817
|
+
async function inboxMove(commit, message, edit) {
|
|
818
|
+
const { gcfRoot } = await lib('gcf-paths');
|
|
819
|
+
const root = gcfRoot();
|
|
820
|
+
if (!commit) {
|
|
821
|
+
await edit(root);
|
|
822
|
+
|
|
823
|
+
return null;
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
const { commitMove } = await lib('unit-ops');
|
|
827
|
+
const inbox = path.join(root, 'inbox.md');
|
|
828
|
+
|
|
829
|
+
// Raw input (merge=union): lines added before without a commit ride along with this one.
|
|
830
|
+
return commitMove(process.cwd(), message, {
|
|
831
|
+
files: [inbox],
|
|
832
|
+
inputs: [inbox],
|
|
833
|
+
mutate: async () => {
|
|
834
|
+
await edit(root);
|
|
835
|
+
},
|
|
836
|
+
commit: true,
|
|
837
|
+
});
|
|
838
|
+
}
|
|
839
|
+
|
|
739
840
|
function registerInbox(gcf) {
|
|
740
841
|
const inbox = gcf
|
|
741
842
|
.command('inbox')
|
|
@@ -749,21 +850,34 @@ function registerInbox(gcf) {
|
|
|
749
850
|
);
|
|
750
851
|
});
|
|
751
852
|
inbox
|
|
752
|
-
.command('add
|
|
853
|
+
.command('add [text]')
|
|
753
854
|
.description('Append an entry')
|
|
754
855
|
.option('--source <source>', 'playtest | ci | agent | operator', 'operator')
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
856
|
+
// A text that starts with a hyphen ("-5 fps on level 3") reads as a flag at every level of
|
|
857
|
+
// ai-factory's commands, `--` included: as `--text=<text>` it stays one value.
|
|
858
|
+
.option('--text <text>', 'the entry, when it starts with a hyphen')
|
|
859
|
+
.option('--commit', 'commit it (gcf(inbox)): for a caller with no console, e.g. a board')
|
|
860
|
+
.action(async (arg, o) => {
|
|
861
|
+
const text = o.text ?? arg;
|
|
862
|
+
if (!text || (o.text !== undefined && arg !== undefined))
|
|
863
|
+
throw new Error('give the entry once: as the argument, or as --text=<text>');
|
|
864
|
+
const { addInbox } = await lib('inbox');
|
|
865
|
+
let id = '';
|
|
866
|
+
const hash = await inboxMove(o.commit, 'gcf(inbox): add', async (root) => {
|
|
867
|
+
id = (await addInbox(root, text, o.source)).id;
|
|
868
|
+
});
|
|
869
|
+
console.log(`${id}${shortHash(hash)}`);
|
|
759
870
|
});
|
|
760
871
|
inbox
|
|
761
872
|
.command('drop <in-id>')
|
|
762
873
|
.description('Erase an entry: triaged as noise, a duplicate, or turned into an item')
|
|
763
|
-
.
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
874
|
+
.option('--commit', 'commit it (gcf(inbox)): for a caller with no console, e.g. a board')
|
|
875
|
+
.action(async (id, o) => {
|
|
876
|
+
const { dropInbox } = await lib('inbox');
|
|
877
|
+
const hash = await inboxMove(o.commit, `gcf(inbox): drop ${id}`, (root) =>
|
|
878
|
+
dropInbox(root, id),
|
|
879
|
+
);
|
|
880
|
+
console.log(`erased ${id}${shortHash(hash)}`);
|
|
767
881
|
});
|
|
768
882
|
}
|
|
769
883
|
|
|
@@ -794,7 +908,7 @@ function registerLive(gcf) {
|
|
|
794
908
|
.option('--json', 'machine output')
|
|
795
909
|
.action(async (o) => {
|
|
796
910
|
const r = await (await lib('live')).checkLive(process.cwd());
|
|
797
|
-
out(o.json, r.issues, (l) => (l.length ? l.join('\n') : 'clean'));
|
|
911
|
+
out(o.json || live.opts().json, r.issues, (l) => (l.length ? l.join('\n') : 'clean'));
|
|
798
912
|
if (r.issues.length) process.exitCode = 1;
|
|
799
913
|
});
|
|
800
914
|
}
|
|
@@ -807,6 +921,7 @@ function registerPlan(gcf) {
|
|
|
807
921
|
.description('Deterministic plan check: it exists, has task checkboxes, readset in budget')
|
|
808
922
|
.option('--json', 'machine output')
|
|
809
923
|
.action(async (id, o) => {
|
|
924
|
+
await touchSession(id);
|
|
810
925
|
const r = await (await lib('plan-check')).planCheck(process.cwd(), id);
|
|
811
926
|
out(o.json, r, (x) =>
|
|
812
927
|
x.ok ? `${x.plan}: ${x.done}/${x.tasks} tasks done` : x.problems.join('\n'),
|
|
@@ -822,6 +937,7 @@ function registerVerify(gcf) {
|
|
|
822
937
|
.option('--mark', 'in its worktree: green and committed → commit the GCF-Verified trailer')
|
|
823
938
|
.option('--json', 'machine output')
|
|
824
939
|
.action(async (id, o) => {
|
|
940
|
+
await touchSession(id);
|
|
825
941
|
const r = o.mark
|
|
826
942
|
? await (await lib('integrate')).markVerified(process.cwd(), id)
|
|
827
943
|
: await (await lib('verify')).verifyItem(process.cwd(), id);
|
|
@@ -1009,6 +1125,7 @@ export function register(program) {
|
|
|
1009
1125
|
registerMoves(gcf);
|
|
1010
1126
|
registerItem(gcf);
|
|
1011
1127
|
registerItemMoves(gcf);
|
|
1128
|
+
registerSession(gcf);
|
|
1012
1129
|
registerDecide(gcf);
|
|
1013
1130
|
registerInbox(gcf);
|
|
1014
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
|
@@ -12,29 +12,82 @@ import { verifiedBranches, worktreeOf } from './integrate.js';
|
|
|
12
12
|
import { resolveInside } from './safe-path.js';
|
|
13
13
|
import { wipOf } from './audit-core.js';
|
|
14
14
|
import { runHolder } from './main-lock.js';
|
|
15
|
+
import { ITEM_TYPES } from './validate-item.js';
|
|
16
|
+
import { movesOf } from './transitions.js';
|
|
17
|
+
import { listQuestions } from './questions.js';
|
|
18
|
+
import { sessionActive } from './session-mark.js';
|
|
19
|
+
|
|
20
|
+
// The version of the JSON below (Methodology §11): a board refuses one it does not know. Bump it
|
|
21
|
+
// when a field changes meaning or goes away; a new field does not bump it.
|
|
22
|
+
export const BOARD_SCHEMA = 1;
|
|
15
23
|
|
|
16
24
|
const COLUMNS = ['ready', 'blocked', 'awaiting-verdict', 'awaiting-decision', 'unplayed', 'parked'];
|
|
17
25
|
|
|
18
26
|
/**
|
|
19
|
-
* Plan progress `{tasks, done}` from its checkboxes
|
|
20
|
-
* outside the project or not a file reads as `{error}`, the
|
|
27
|
+
* Plan progress `{tasks, done}` from its checkboxes and the plan's project path, both null
|
|
28
|
+
* without a plan file (§3.3); a plan: outside the project or not a file reads as `{error}`, the
|
|
29
|
+
* rest of the board still shows.
|
|
21
30
|
*/
|
|
22
31
|
async function progress(projectDir, item) {
|
|
32
|
+
const plan = item.data.plan ?? `.ai-factory/plans/${item.data.id}.md`;
|
|
23
33
|
try {
|
|
24
|
-
const plan = item.data.plan ?? `.ai-factory/plans/${item.data.id}.md`;
|
|
25
34
|
const file = resolveInside(projectDir, plan);
|
|
26
|
-
if (!existsSync(file)) return null;
|
|
35
|
+
if (!existsSync(file)) return { plan: null, plan_file: null };
|
|
27
36
|
const text = await readFile(file, 'utf8');
|
|
28
37
|
|
|
29
38
|
return {
|
|
30
|
-
|
|
31
|
-
|
|
39
|
+
plan: {
|
|
40
|
+
tasks: (text.match(/^\s*- \[[ xX]\] /gm) ?? []).length,
|
|
41
|
+
done: (text.match(/^\s*- \[[xX]\] /gm) ?? []).length,
|
|
42
|
+
},
|
|
43
|
+
plan_file: plan,
|
|
32
44
|
};
|
|
33
45
|
} catch (e) {
|
|
34
|
-
return { error: e.message };
|
|
46
|
+
return { plan: { error: e.message }, plan_file: null };
|
|
35
47
|
}
|
|
36
48
|
}
|
|
37
49
|
|
|
50
|
+
/** The lines `- <a> | <b> | …` of a gcf record file, split on the pipes. */
|
|
51
|
+
async function records(root, name) {
|
|
52
|
+
const file = path.join(root, name);
|
|
53
|
+
if (!existsSync(file)) return [];
|
|
54
|
+
|
|
55
|
+
return (await readFile(file, 'utf8')).split(/\r?\n/).flatMap((l) => {
|
|
56
|
+
const m = l.match(/^- (\S+)\s*\|(.*)$/);
|
|
57
|
+
|
|
58
|
+
return m ? [[m[1], ...m[2].split('|').map((f) => f.trim())]] : [];
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** history.md: `- <id> | <event> | <date>` (§9). */
|
|
63
|
+
const historyOf = async (root) =>
|
|
64
|
+
(await records(root, 'history.md')).map(([id, event, date]) => ({
|
|
65
|
+
id,
|
|
66
|
+
event: event || null,
|
|
67
|
+
date: date || null,
|
|
68
|
+
}));
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* graveyard.md: `- <id> | <type> | <reason> | tags: <tags>`; a 0.3 line has no type, and its
|
|
72
|
+
* second field is a type only when it names one (§9).
|
|
73
|
+
*/
|
|
74
|
+
const graveyardOf = async (root) =>
|
|
75
|
+
(await records(root, 'graveyard.md')).map(([id, ...fields]) => {
|
|
76
|
+
const tags = fields.at(-1)?.startsWith('tags:') ? fields.pop() : null;
|
|
77
|
+
const typed = fields.length > 1 && ITEM_TYPES.includes(fields[0]);
|
|
78
|
+
|
|
79
|
+
return {
|
|
80
|
+
id,
|
|
81
|
+
type: typed ? fields[0] : null,
|
|
82
|
+
reason: (typed ? fields.slice(1) : fields).join(' | ') || null,
|
|
83
|
+
tags: (tags ?? '')
|
|
84
|
+
.slice('tags:'.length)
|
|
85
|
+
.split(',')
|
|
86
|
+
.map((t) => t.trim())
|
|
87
|
+
.filter(Boolean),
|
|
88
|
+
};
|
|
89
|
+
});
|
|
90
|
+
|
|
38
91
|
/** Verified branches; none outside a repository. */
|
|
39
92
|
function verifiedOf(projectDir) {
|
|
40
93
|
try {
|
|
@@ -44,21 +97,6 @@ function verifiedOf(projectDir) {
|
|
|
44
97
|
}
|
|
45
98
|
}
|
|
46
99
|
|
|
47
|
-
/** Open question records of open-questions.md: id and class. */
|
|
48
|
-
async function questions(root) {
|
|
49
|
-
const file = path.join(root, 'open-questions.md');
|
|
50
|
-
if (!existsSync(file)) return [];
|
|
51
|
-
|
|
52
|
-
return (await readFile(file, 'utf8'))
|
|
53
|
-
.replace(/\r\n/g, '\n')
|
|
54
|
-
.split(/\n(?=- id:)/)
|
|
55
|
-
.flatMap((rec) => {
|
|
56
|
-
const id = rec.match(/^- id:\s*(\S+)/)?.[1];
|
|
57
|
-
|
|
58
|
-
return id ? [{ id, class: rec.match(/^\s+class:\s*(\S+)/m)?.[1] ?? null }] : [];
|
|
59
|
-
});
|
|
60
|
-
}
|
|
61
|
-
|
|
62
100
|
/** The phase status, or the reason it cannot be read: the board still shows the rest. */
|
|
63
101
|
async function phaseOf(projectDir) {
|
|
64
102
|
try {
|
|
@@ -68,48 +106,92 @@ async function phaseOf(projectDir) {
|
|
|
68
106
|
}
|
|
69
107
|
}
|
|
70
108
|
|
|
109
|
+
/** The release block with every field: one a malformed block lacks is null (live check says so). */
|
|
110
|
+
const releaseOf = (r) =>
|
|
111
|
+
r && {
|
|
112
|
+
version: r.version,
|
|
113
|
+
...Object.fromEntries(['channel', 'date', 'phase'].map((k) => [k, r[k] ?? null])),
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
/** The run log of an item, project-relative, or null before its first run. */
|
|
117
|
+
function logOf(projectDir, id) {
|
|
118
|
+
const log = `.gcf-session/runs/${id}.log`;
|
|
119
|
+
|
|
120
|
+
return existsSync(path.join(projectDir, log)) ? log : null;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// Fields of the item file a board shows as they are: null or [] when the file has none.
|
|
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);
|
|
137
|
+
const LISTS = ['children', 'system', 'depends'];
|
|
138
|
+
|
|
139
|
+
/** One item of the board. */
|
|
140
|
+
async function itemOf(projectDir, c, verified) {
|
|
141
|
+
const id = String(c.data.id);
|
|
142
|
+
// A trailer counts only while the item waits on main for its integrate.
|
|
143
|
+
const ready = c.data.status === 'ready' && verified.has(id);
|
|
144
|
+
// `running` and `verify` are not stored (§4): a live harness run, or a worktree of a ready
|
|
145
|
+
// item, is a run; a trailer is a passed DoD.
|
|
146
|
+
const running =
|
|
147
|
+
runHolder(projectDir, id) !== null ||
|
|
148
|
+
sessionActive(projectDir, id) ||
|
|
149
|
+
(c.data.status === 'ready' && !ready && existsSync(worktreeOf(projectDir, id)));
|
|
150
|
+
|
|
151
|
+
return {
|
|
152
|
+
id,
|
|
153
|
+
type: c.data.type,
|
|
154
|
+
gate: c.data.gate,
|
|
155
|
+
status: c.data.status,
|
|
156
|
+
...Object.fromEntries(SCALARS.map((k) => [k, c.data[k] ?? null])),
|
|
157
|
+
...Object.fromEntries(LISTS.map((k) => [k, c.data[k] ?? []])),
|
|
158
|
+
...(c.data.severity ? { severity: c.data.severity } : {}),
|
|
159
|
+
...(c.data.blocked ? { blocked: c.data.blocked } : {}),
|
|
160
|
+
running,
|
|
161
|
+
verified: ready,
|
|
162
|
+
// What a board may offer (§4.1): nothing while a run drives the item.
|
|
163
|
+
moves: running ? [] : movesOf(c.data),
|
|
164
|
+
file: `gcf/work/${c.name}`,
|
|
165
|
+
log: logOf(projectDir, id),
|
|
166
|
+
...(await progress(projectDir, c)),
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
|
|
71
170
|
export async function board(projectDir) {
|
|
72
171
|
const root = path.join(path.resolve(projectDir), 'gcf');
|
|
73
172
|
const ledger = await buildLedger(projectDir);
|
|
74
173
|
const verified = verifiedOf(projectDir);
|
|
75
174
|
const items = [];
|
|
76
|
-
for (const c of ledger.items)
|
|
77
|
-
|
|
78
|
-
// A trailer counts only while the item waits on main for its integrate.
|
|
79
|
-
const ready = c.data.status === 'ready' && verified.has(id);
|
|
80
|
-
items.push({
|
|
81
|
-
id,
|
|
82
|
-
type: c.data.type,
|
|
83
|
-
gate: c.data.gate,
|
|
84
|
-
status: c.data.status,
|
|
85
|
-
title: c.data.title ?? null,
|
|
86
|
-
parent: c.data.parent ?? null,
|
|
87
|
-
children: c.data.children ?? [],
|
|
88
|
-
system: c.data.system ?? [],
|
|
89
|
-
...(c.data.severity ? { severity: c.data.severity } : {}),
|
|
90
|
-
...(c.data.blocked ? { blocked: c.data.blocked } : {}),
|
|
91
|
-
// `running` and `verify` are not stored (§4): a live harness run, or a worktree of a ready
|
|
92
|
-
// item, is a run; a trailer is a passed DoD.
|
|
93
|
-
running:
|
|
94
|
-
runHolder(projectDir, id) !== null ||
|
|
95
|
-
(c.data.status === 'ready' && !ready && existsSync(worktreeOf(projectDir, id))),
|
|
96
|
-
verified: ready,
|
|
97
|
-
plan: await progress(projectDir, c),
|
|
98
|
-
});
|
|
99
|
-
}
|
|
175
|
+
for (const c of ledger.items) items.push(await itemOf(projectDir, c, verified));
|
|
176
|
+
items.sort(byPriority);
|
|
100
177
|
|
|
101
178
|
const config = await readConfig(root);
|
|
102
179
|
const live = await readLive(root);
|
|
103
180
|
|
|
104
181
|
return {
|
|
182
|
+
schema: BOARD_SCHEMA,
|
|
183
|
+
run_mode: config.run_mode,
|
|
105
184
|
phase: await phaseOf(projectDir),
|
|
106
|
-
|
|
185
|
+
// A malformed block reads with fields missing (live check names it): null, not absent.
|
|
186
|
+
release: releaseOf(live.release),
|
|
107
187
|
columns: Object.fromEntries(
|
|
108
188
|
COLUMNS.map((s) => [s, items.filter((i) => i.status === s).map((i) => i.id)]),
|
|
109
189
|
),
|
|
110
190
|
items,
|
|
111
191
|
wip: wipOf(ledger.items, config),
|
|
112
192
|
inbox: await listInbox(root),
|
|
113
|
-
questions: await
|
|
193
|
+
questions: await listQuestions(root),
|
|
194
|
+
history: await historyOf(root),
|
|
195
|
+
graveyard: await graveyardOf(root),
|
|
114
196
|
};
|
|
115
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') : '';
|
|
@@ -238,7 +238,7 @@ async function integrateMerged(projectDir, id, item) {
|
|
|
238
238
|
if (!item || item.data.status !== 'ready' || movedSinceMerge(projectDir, id))
|
|
239
239
|
return { id, skipped: `${branchOf(id)} is in main already`, ...cleanUp(projectDir, id) };
|
|
240
240
|
|
|
241
|
-
return {
|
|
241
|
+
return { ...(await recordMove(projectDir, item)), id, ...cleanUp(projectDir, id) };
|
|
242
242
|
}
|
|
243
243
|
|
|
244
244
|
async function integrateOne(projectDir, id) {
|
|
@@ -250,7 +250,7 @@ async function integrateOne(projectDir, id) {
|
|
|
250
250
|
if (git(projectDir, ancestor, { check: false }).ok) return integrateMerged(projectDir, id, item);
|
|
251
251
|
if (!item || item.data.status !== 'ready')
|
|
252
252
|
return { id, skipped: `${id} is ${item ? item.data.status : 'not a work item'} on main` };
|
|
253
|
-
if (item.data.type === 'spike') return {
|
|
253
|
+
if (item.data.type === 'spike') return { ...(await integrateSpike(projectDir, item)), id };
|
|
254
254
|
const foreign = foreignGcfEdits(projectDir, id);
|
|
255
255
|
if (foreign.length)
|
|
256
256
|
return {
|
|
@@ -263,7 +263,7 @@ async function integrateOne(projectDir, id) {
|
|
|
263
263
|
if (failed) return { id, ...failed };
|
|
264
264
|
const moved = await recordMove(projectDir, (await buildLedger(projectDir)).item(id));
|
|
265
265
|
|
|
266
|
-
return {
|
|
266
|
+
return { ...moved, id, ...cleanUp(projectDir, id) };
|
|
267
267
|
}
|
|
268
268
|
|
|
269
269
|
/**
|
package/lib/live.js
CHANGED
|
@@ -66,7 +66,7 @@ export function parseLive(text) {
|
|
|
66
66
|
/** A record line of capabilities or known-issues; false when the line is not one. */
|
|
67
67
|
const recordLine = (line) => {
|
|
68
68
|
const start = line.match(/^- id:\s*(\S+)\s*$/);
|
|
69
|
-
if (start && section) {
|
|
69
|
+
if (start && section && section !== 'release') {
|
|
70
70
|
rec = { id: start[1], summary: '' };
|
|
71
71
|
out[section].push(rec);
|
|
72
72
|
|
|
@@ -106,7 +106,11 @@ const recordText = (r) =>
|
|
|
106
106
|
|
|
107
107
|
const byId = (a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0);
|
|
108
108
|
|
|
109
|
-
/**
|
|
109
|
+
/**
|
|
110
|
+
* LIVE.md text: records sorted by id, so two branches touching different ids merge cleanly.
|
|
111
|
+
* @param {{phase?: string | null, release?: LiveRelease | null, capabilities: LiveRecord[],
|
|
112
|
+
* knownIssues: LiveRecord[]}} live
|
|
113
|
+
*/
|
|
110
114
|
export function serializeLive({ phase = null, release = null, capabilities, knownIssues }) {
|
|
111
115
|
const block = (key, list) =>
|
|
112
116
|
`${key}:\n${list.length ? [...list].sort(byId).map(recordText).join('\n') : NONE[key]}\n`;
|
package/lib/playtest-list.js
CHANGED
|
@@ -73,7 +73,7 @@ export async function playtestSession(
|
|
|
73
73
|
commit: git(projectDir, ['rev-parse', '--short', 'HEAD']),
|
|
74
74
|
// No commit (no repository, or none yet): the build is unknown, not clean.
|
|
75
75
|
dirty: dirty === null ? null : Boolean(dirty),
|
|
76
|
-
build: null,
|
|
76
|
+
build: /** @type {ReturnType<typeof runDod> | null} */ (null),
|
|
77
77
|
items,
|
|
78
78
|
};
|
|
79
79
|
if (build) session.build = runDod(projectDir, await readConfig(root), 'build', stdio);
|
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/transitions.js
CHANGED
|
@@ -37,6 +37,31 @@ export function allowedFrom(move, status) {
|
|
|
37
37
|
/** Statuses `move` starts from, for error messages. */
|
|
38
38
|
export const fromOf = (move) => FROM[move] ?? [];
|
|
39
39
|
|
|
40
|
+
export const FEATURE_ONLY = ['close', 'continue', 'kill'];
|
|
41
|
+
const BUG_ONLY = ['wontfix', 'known'];
|
|
42
|
+
|
|
43
|
+
/** Whether a decision fits an item's type: a feature has its own, wontfix and known a bug's. */
|
|
44
|
+
export const decisionFits = (choice, type) =>
|
|
45
|
+
FEATURE_ONLY.includes(choice) === (type === 'feature') &&
|
|
46
|
+
(!BUG_ONLY.includes(choice) || type === 'bug');
|
|
47
|
+
|
|
48
|
+
/** Whether a move fits the item beyond its status: its gate for submit and close, its type. */
|
|
49
|
+
function fits(move, { type, gate }) {
|
|
50
|
+
const [cmd, arg] = move.split(':');
|
|
51
|
+
if (cmd === 'submit') return gate !== 'auto';
|
|
52
|
+
if (cmd === 'close') return gate === 'auto';
|
|
53
|
+
|
|
54
|
+
return cmd !== 'decide' || decisionFits(arg, type);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* The moves an item may take now (§4.1), named as `allowedFrom` names them: `verdict:keep`,
|
|
59
|
+
* `decide:go`, `unblock`… A board offers these and nothing else; each command still checks the
|
|
60
|
+
* rest itself (dependencies, a live: delta, the tune limit).
|
|
61
|
+
*/
|
|
62
|
+
export const movesOf = (data) =>
|
|
63
|
+
Object.keys(FROM).filter((m) => allowedFrom(m, data.status) && fits(m, data));
|
|
64
|
+
|
|
40
65
|
/** A status an item FILE may carry. */
|
|
41
66
|
export function isStorable(status) {
|
|
42
67
|
return STORED.includes(status);
|
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
|
@@ -11,9 +11,8 @@ import path from 'node:path';
|
|
|
11
11
|
import { parse, serialize } from './frontmatter.js';
|
|
12
12
|
import { buildLedger } from './ledger.js';
|
|
13
13
|
import { readConfig } from './gcf-paths.js';
|
|
14
|
-
import { atomicWrite
|
|
15
|
-
import {
|
|
16
|
-
import { allowedFrom, fromOf } from './transitions.js';
|
|
14
|
+
import { atomicWrite } from './safe-path.js';
|
|
15
|
+
import { allowedFrom, decisionFits, FEATURE_ONLY, fromOf } from './transitions.js';
|
|
17
16
|
import { ITEM_TYPES, DEFAULT_GATE, GATES, SEVERITIES, UNIT_ITEM_TYPES } from './validate-item.js';
|
|
18
17
|
import { isValidId } from './id.js';
|
|
19
18
|
import { commitMove, addConstraints } from './unit-ops.js';
|
|
@@ -39,6 +38,7 @@ import {
|
|
|
39
38
|
import { dropInbox } from './inbox.js';
|
|
40
39
|
import { refuseByPhase } from './phase.js';
|
|
41
40
|
import { runHolder } from './main-lock.js';
|
|
41
|
+
import { releaseSession, sessionActive } from './session-mark.js';
|
|
42
42
|
|
|
43
43
|
/**
|
|
44
44
|
* Options of a committed move; each move reads the ones it needs.
|
|
@@ -174,6 +174,13 @@ async function begin(projectDir, id, move) {
|
|
|
174
174
|
`${id} is driven by a harness run (pid ${run}): wait until that run ends; if no harness ` +
|
|
175
175
|
`run is going, delete .gcf-session/runs/${id}.lock`,
|
|
176
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
|
+
);
|
|
177
184
|
const files = [item.file];
|
|
178
185
|
const steps = [];
|
|
179
186
|
const liveOps = [];
|
|
@@ -203,13 +210,18 @@ async function begin(projectDir, id, move) {
|
|
|
203
210
|
}
|
|
204
211
|
|
|
205
212
|
/** Commit the move as `gcf(<move>): <tail>` with every file it touched (item edits included). */
|
|
206
|
-
|
|
207
|
-
commitMove(m.projectDir, `gcf(${move}): ${tail}`, {
|
|
213
|
+
async function finish(m, move, tail, commit) {
|
|
214
|
+
const hash = await commitMove(m.projectDir, `gcf(${move}): ${tail}`, {
|
|
208
215
|
files: m.files,
|
|
209
216
|
mutate: m.run,
|
|
210
217
|
commit,
|
|
211
218
|
inputs: [m.item.file],
|
|
212
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
|
+
}
|
|
213
225
|
|
|
214
226
|
const writeItem = (item, data, body = item.body) => atomicWrite(item.file, serialize(data, body));
|
|
215
227
|
|
|
@@ -646,10 +658,9 @@ const DECIDE = {
|
|
|
646
658
|
},
|
|
647
659
|
kill: killFeature,
|
|
648
660
|
};
|
|
649
|
-
const FEATURE_ONLY = ['close', 'continue', 'kill'];
|
|
650
|
-
|
|
651
661
|
/** Refuse a decision that does not fit the item's type. */
|
|
652
662
|
function fitDecision(choice, item) {
|
|
663
|
+
if (decisionFits(choice, item.data.type)) return;
|
|
653
664
|
const feature = item.data.type === 'feature';
|
|
654
665
|
if (FEATURE_ONLY.includes(choice) !== feature)
|
|
655
666
|
throw new Error(
|
|
@@ -657,8 +668,7 @@ function fitDecision(choice, item) {
|
|
|
657
668
|
? `${item.data.id} is a feature: decide close | continue | kill`
|
|
658
669
|
: `decide ${choice} is for a feature; ${item.data.id} is a ${item.data.type}`,
|
|
659
670
|
);
|
|
660
|
-
|
|
661
|
-
throw new Error(`decide ${choice} is for a bug; ${item.data.id} is a ${item.data.type}`);
|
|
671
|
+
throw new Error(`decide ${choice} is for a bug; ${item.data.id} is a ${item.data.type}`);
|
|
662
672
|
}
|
|
663
673
|
|
|
664
674
|
/** The operator's decisions (Methodology §4.1): spikes, decision items, bugs and features.
|
|
@@ -727,6 +737,9 @@ export async function dropItem(projectDir, id, opts = {}) {
|
|
|
727
737
|
return { id, hash: await finish(m, 'drop', id, commit), removal };
|
|
728
738
|
}
|
|
729
739
|
|
|
740
|
+
// A leading hyphen would read as a flag of the agent's command line.
|
|
741
|
+
export const RUN_WORD = /^[A-Za-z0-9][\w.-]*$/;
|
|
742
|
+
|
|
730
743
|
/** The version and channel of a new item: well-formed, and only on a release. */
|
|
731
744
|
function checkRelease(spec) {
|
|
732
745
|
const { type, version, channel } = spec;
|
|
@@ -740,6 +753,28 @@ function checkRelease(spec) {
|
|
|
740
753
|
throw new Error('--release and --channel belong to a release item');
|
|
741
754
|
}
|
|
742
755
|
|
|
756
|
+
const RUN_KEYS = ['model', 'effort'];
|
|
757
|
+
|
|
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
|
+
*/
|
|
762
|
+
function runFields(spec) {
|
|
763
|
+
for (const k of RUN_KEYS)
|
|
764
|
+
if (spec[k] !== undefined && !RUN_WORD.test(spec[k]))
|
|
765
|
+
throw new Error(
|
|
766
|
+
`--${k} is one word that starts with a letter or a digit ` +
|
|
767
|
+
'(then letters, digits, dots, hyphens, underscores), e.g. opus',
|
|
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)');
|
|
772
|
+
|
|
773
|
+
return Object.fromEntries(
|
|
774
|
+
[...RUN_KEYS, 'priority'].filter((k) => spec[k] !== undefined).map((k) => [k, spec[k]]),
|
|
775
|
+
);
|
|
776
|
+
}
|
|
777
|
+
|
|
743
778
|
/** Refuse a spec `item new` cannot honour; returns the gate the item gets. */
|
|
744
779
|
function checkNew(ledger, spec) {
|
|
745
780
|
const { id, type, gate, severity } = spec;
|
|
@@ -790,6 +825,7 @@ function optionalFields(spec, gate, since) {
|
|
|
790
825
|
...(spec.milestone ? { milestone: spec.milestone } : {}),
|
|
791
826
|
...(spec.version ? { version: spec.version } : {}),
|
|
792
827
|
...(spec.channel ? { channel: spec.channel } : {}),
|
|
828
|
+
...runFields(spec),
|
|
793
829
|
...(live ? { live } : {}),
|
|
794
830
|
};
|
|
795
831
|
}
|
|
@@ -815,6 +851,47 @@ function newItemText(spec, gate, since) {
|
|
|
815
851
|
return serialize(data, body);
|
|
816
852
|
}
|
|
817
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
|
+
|
|
818
895
|
/**
|
|
819
896
|
* A new work item in ready (or blocked), no commit. A design unit enters work through `take`,
|
|
820
897
|
* not here: its body is the unit's slots. `from` erases the inbox entry it came from.
|
|
@@ -838,14 +915,18 @@ export async function newItem(projectDir, spec) {
|
|
|
838
915
|
writes.push([p.file, serialize({ ...p.data, children }, p.body)]);
|
|
839
916
|
}
|
|
840
917
|
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
918
|
+
// `commit` is for a caller with no console to commit from, a board: a run starts from HEAD.
|
|
919
|
+
// The inbox is raw input (merge=union): lines added without a commit ride along with it.
|
|
920
|
+
const inbox = spec.from ? [path.join(root, 'inbox.md')] : [];
|
|
921
|
+
const hash = await commitMove(projectDir, `gcf(item): ${spec.id}`, {
|
|
922
|
+
files: [...writes.map((w) => w[0]), ...inbox],
|
|
923
|
+
inputs: inbox,
|
|
924
|
+
mutate: async () => {
|
|
844
925
|
for (const [f, text] of writes) await atomicWrite(f, text);
|
|
845
926
|
if (spec.from) await dropInbox(root, spec.from);
|
|
846
|
-
await writeIndex(root);
|
|
847
927
|
},
|
|
848
|
-
|
|
928
|
+
commit: spec.commit ?? false,
|
|
929
|
+
});
|
|
849
930
|
|
|
850
|
-
return { item: `gcf/work/${spec.id}.md
|
|
931
|
+
return { item: `gcf/work/${spec.id}.md`, hash };
|
|
851
932
|
}
|
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
|
|
@@ -6,7 +6,7 @@ gcf_context:
|
|
|
6
6
|
|
|
7
7
|
gcf_work:
|
|
8
8
|
- every piece of work is one work item `gcf/work/<id>.md`: find the item before writing code for a feature, a bug or a tech task, or create it with the gcf-card skill
|
|
9
|
-
- a raw idea, a playtest note or a CI finding that is not work yet goes to `ai-factory gcf inbox add "<text>"`, and the gcf-triage skill sorts it later
|
|
9
|
+
- a raw idea, a playtest note or a CI finding that is not work yet goes to `ai-factory gcf inbox add "<text>"`, and the gcf-triage skill sorts it later; a text that starts with a hyphen goes as `inbox add --text="<text>"`
|
|
10
10
|
- before planning an item, run `ai-factory gcf context <id>` and read only the files it lists under `gcf/`
|
|
11
11
|
- drive a ready item with the gcf-run skill when the project has gcf-harness-aif-ext (a batch at once: `ai-factory gcf-harness run <ids...>`), otherwise with aif-plan and aif-implement
|
|
12
12
|
- after the code, write the item's `live:` delta and run `ai-factory gcf verify <id>`: fix what it reports and run it again until every check is green
|
|
@@ -18,6 +18,9 @@ gcf_work:
|
|
|
18
18
|
- the human plays from `ai-factory gcf playtest session`; each free note from that play goes to `ai-factory gcf inbox add --source playtest "<note>"`
|
|
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
|
+
- 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`
|
|
21
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>"`
|
|
22
25
|
|
|
23
26
|
gcf_forbidden:
|