gcf-aif-ext 0.7.8 → 0.7.9

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 CHANGED
@@ -50,6 +50,11 @@ 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.9
54
+
55
+ - `gcf done`, `gcf drop` (a design unit), `gcf pillar rename|retire`, `gcf phase next` and `gcf question answer` hold the main lock from their first read to their commit, as the work item moves do. Before, two at once could both pass their checks and write over each other: an answer lost, a unit in the history twice.
56
+ - `gcf claim` says the mark lives 2 hours without a gcf command, as it does (it said 30 minutes).
57
+
53
58
  ### 0.7.8
54
59
 
55
60
  - An open question's field written as a YAML block (`assumed: |` or `>`, and the lines under it) reads as its text: before, `gcf board --json` gave the block sign `|`. Every reader of `gcf/open-questions.md` (the board, `design check`, the audit, `context`, the index) reads the fields the same way:
package/commands/gcf.js CHANGED
@@ -33,7 +33,7 @@ function registerSession(gcf) {
33
33
  .command('claim <id>')
34
34
  .description(
35
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',
36
+ 'move ends it, release, or 2 hours without a gcf command on it',
37
37
  )
38
38
  .action(async (id) => {
39
39
  const { buildLedger } = await lib('ledger');
@@ -977,6 +977,10 @@ function registerBoard(gcf) {
977
977
  ].join('\n'),
978
978
  );
979
979
  });
980
+ }
981
+
982
+ /** The human's settings a board edits through the core: config keys and agent deny rules. */
983
+ function registerSettings(gcf) {
980
984
  const config = gcf
981
985
  .command('config')
982
986
  .description("The human's settings of gcf/config.yml: list them, or set one (commit)")
@@ -1189,6 +1193,7 @@ export function register(program) {
1189
1193
  registerVerify(gcf);
1190
1194
  registerIntegrate(gcf);
1191
1195
  registerBoard(gcf);
1196
+ registerSettings(gcf);
1192
1197
  registerPhase(gcf);
1193
1198
  registerHooksInstall(gcf);
1194
1199
  registerLint(gcf);
package/extension.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "$schema": "https://raw.githubusercontent.com/lee-to/ai-factory/2.x/schemas/extension.schema.json",
3
3
  "name": "gcf-aif-ext",
4
- "version": "0.7.8",
4
+ "version": "0.7.9",
5
5
  "description": "Game Context Framework for AI Factory",
6
6
  "skills": [
7
7
  "skills/gcf-init",
@@ -7,10 +7,12 @@
7
7
  import { readFile } from 'node:fs/promises';
8
8
  import { existsSync } from 'node:fs';
9
9
  import path from 'node:path';
10
- import YAML from 'yaml';
11
10
  import { commitMove } from './unit-ops.js';
12
11
  import { atomicWrite } from './safe-path.js';
13
12
  import { gcfRoot } from './gcf-paths.js';
13
+ import { configDoc, ensureMap } from './config-set.js';
14
+
15
+ const GROUPS_SHAPE = 'gcf/config.yml: agent.groups must map a group id to true or false';
14
16
 
15
17
  // A shell command pattern turns into a rule for both shell tools: on Windows the agent runs
16
18
  // commands through PowerShell, which Bash rules do not cover.
@@ -75,7 +77,8 @@ export const DENY_GROUPS = [
75
77
  {
76
78
  id: 'human-moves',
77
79
  label:
78
- "the human's GCF moves: verdict, decide, answer, unblock, phase, pillars, settings, permissions, setup",
80
+ "the human's GCF moves: verdict, decide, answer, unblock, phase, pillars, settings, " +
81
+ 'permissions, setup',
79
82
  on: true,
80
83
  rules: shell(
81
84
  'ai-factory gcf verdict*',
@@ -159,12 +162,12 @@ function rules(agent, key) {
159
162
  */
160
163
  function choices(agent) {
161
164
  const g = agent.groups ?? {};
162
- if (typeof g !== 'object' || Array.isArray(g))
163
- throw new Error('gcf/config.yml: agent.groups must map a group id to true or false');
165
+ if (typeof g !== 'object' || Array.isArray(g)) throw new Error(GROUPS_SHAPE);
164
166
  for (const [id, on] of Object.entries(g))
165
167
  if (!known(id) || typeof on !== 'boolean')
166
168
  throw new Error(
167
- `gcf/config.yml: agent.groups.${id} must be true or false, for one of ${GROUP_IDS.join(' | ')}`,
169
+ `gcf/config.yml: agent.groups.${id} must be true or false, ` +
170
+ `for one of ${GROUP_IDS.join(' | ')}`,
168
171
  );
169
172
 
170
173
  return g;
@@ -214,18 +217,9 @@ export async function setPermissions(projectDir, change, { commit = true } = {})
214
217
  const hash = await commitMove(projectDir, 'gcf(permissions): agent deny rules', {
215
218
  files: [file],
216
219
  mutate: async () => {
217
- const doc = YAML.parseDocument(existsSync(file) ? await readFile(file, 'utf8') : '');
218
- if (doc.errors.length) throw new Error(`gcf/config.yml: ${doc.errors[0].message}`);
219
- if (!YAML.isMap(doc.contents)) doc.contents = doc.createNode({});
220
- // A blank `agent:` (or one holding only comments) is no map yet.
221
- const agent = doc.get('agent', true);
222
- if (agent != null && !YAML.isMap(agent) && !(YAML.isScalar(agent) && agent.value == null))
223
- throw new Error('gcf/config.yml: agent must be a mapping');
224
- if (!YAML.isMap(agent)) doc.set('agent', doc.createNode({}));
225
- const groups = doc.getIn(['agent', 'groups'], true);
226
- if (groups != null && !YAML.isMap(groups) && !(YAML.isScalar(groups) && groups.value == null))
227
- throw new Error('gcf/config.yml: agent.groups must map a group id to true or false');
228
- if (!YAML.isMap(groups)) doc.setIn(['agent', 'groups'], doc.createNode({}));
220
+ const doc = configDoc(existsSync(file) ? await readFile(file, 'utf8') : '');
221
+ ensureMap(doc, ['agent'], 'gcf/config.yml: agent must be a mapping');
222
+ ensureMap(doc, ['agent', 'groups'], GROUPS_SHAPE);
229
223
  const current = agentOf({ agent: doc.toJS().agent });
230
224
  checkChange(change, current);
231
225
  for (const id of change.on ?? []) doc.setIn(['agent', 'groups', id], true);
package/lib/board.js CHANGED
@@ -231,7 +231,7 @@ export async function board(projectDir) {
231
231
  return {
232
232
  schema: BOARD_SCHEMA,
233
233
  run_mode: config.run_mode,
234
- // What the harness hands its agent runs (Harness-PRD §7): the deny rules and the context limits.
234
+ // What the harness hands its agent runs (Harness-PRD §7): deny rules and the context limits.
235
235
  agent: agentOf(config),
236
236
  // The human's settings of config.yml, each with its value and default (`gcf config set`).
237
237
  settings: settingsOf(await readRawConfig(root)),
package/lib/config-set.js CHANGED
@@ -16,13 +16,31 @@ import { configOf, DEFAULT_CONFIG, gcfRoot, RUN_MODES } from './gcf-paths.js';
16
16
  // Thrown inside the move to leave without a write or a commit.
17
17
  const NO_CHANGE = new Error('no change');
18
18
 
19
+ /** gcf/config.yml as a YAML document with a map on top: an edit keeps its comments. */
20
+ export function configDoc(text) {
21
+ const doc = YAML.parseDocument(text);
22
+ if (doc.errors.length) throw new Error(`gcf/config.yml: ${doc.errors[0].message}`);
23
+ if (!YAML.isMap(doc.contents)) doc.contents = /** @type {any} */ (doc.createNode({}));
24
+
25
+ return doc;
26
+ }
27
+
28
+ /** Make `keys` a map. A blank key (`dod:` with nothing or only comments under it) is none yet. */
29
+ export function ensureMap(doc, keys, error) {
30
+ const node = doc.getIn(keys, true);
31
+ if (YAML.isMap(node)) return;
32
+ if (node != null && !(YAML.isScalar(node) && node.value == null)) throw new Error(error);
33
+ doc.setIn(keys, doc.createNode({}));
34
+ }
35
+
19
36
  /** @type {{ key: string, kind: Kind, label: string }[]} */
20
37
  export const SETTINGS = [
21
38
  {
22
39
  key: 'run_mode',
23
40
  kind: 'mode',
24
41
  label:
25
- 'how a run drives an item: worktree (several at once) or main (one at a time in the project folder)',
42
+ 'how a run drives an item: worktree (several at once) or main (one at a time in the ' +
43
+ 'project folder)',
26
44
  },
27
45
  { key: 'dod.test', kind: 'command', label: "the project's test command for the DoD, or none" },
28
46
  { key: 'dod.build', kind: 'command', label: "the project's build command for the DoD, or none" },
@@ -132,21 +150,9 @@ export async function setConfig(projectDir, key, text, { commit = true } = {}) {
132
150
  files: [file],
133
151
  mutate: async () => {
134
152
  const text = existsSync(file) ? await readFile(file, 'utf8') : '';
135
- const doc = YAML.parseDocument(text);
136
- if (doc.errors.length) throw new Error(`gcf/config.yml: ${doc.errors[0].message}`);
137
- if (!YAML.isMap(doc.contents)) doc.contents = doc.createNode({});
138
- // A blank parent (`dod:` with nothing under it) is no map yet.
139
- if (keys.length > 1) {
140
- const parent = doc.get(keys[0], true);
141
- if (
142
- parent != null &&
143
- !YAML.isMap(parent) &&
144
- !(YAML.isScalar(parent) && parent.value == null)
145
- )
146
- throw new Error(`gcf/config.yml: ${keys[0]} must map its keys`);
147
- if (!YAML.isMap(parent)) doc.set(keys[0], doc.createNode({}));
148
- }
149
-
153
+ const doc = configDoc(text);
154
+ if (keys.length > 1)
155
+ ensureMap(doc, [keys[0]], `gcf/config.yml: ${keys[0]} must map its keys`);
150
156
  if (value === null) doc.deleteIn(keys);
151
157
  else doc.setIn(keys, value);
152
158
  raw = doc.toJS();
package/lib/gcf-paths.js CHANGED
@@ -68,6 +68,7 @@ export function configOf(parsed) {
68
68
  if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed))
69
69
  throw new Error('gcf/config.yml must be a key: value mapping');
70
70
  // A blank `key:` parses as null and means "not set", like an absent key.
71
+ /** @type {Record<string, any>} */
71
72
  const config = {
72
73
  ...DEFAULT_CONFIG,
73
74
  ...Object.fromEntries(Object.entries(parsed).filter(([, v]) => v !== null)),
package/lib/main-lock.js CHANGED
@@ -140,3 +140,14 @@ export async function withMainLock(projectDir, fn, { waitMs = 60000 } = {}) {
140
140
  if (text.includes(token)) await unlink(file).catch(() => {});
141
141
  }
142
142
  }
143
+
144
+ /**
145
+ * A committed move holds the main lock from its first read to its commit (Methodology §7): one
146
+ * that read the ledger before taking it would write over a move committed meanwhile.
147
+ * @template T
148
+ * @param {string} projectDir
149
+ * @param {boolean} commit
150
+ * @param {() => Promise<T>} move
151
+ */
152
+ export const locked = (projectDir, commit, move) =>
153
+ commit ? withMainLock(projectDir, move) : move();
package/lib/phase.js CHANGED
@@ -8,6 +8,7 @@ import { readLiveStrict, writeLive, liveFile } from './live.js';
8
8
  import { buildLedger } from './ledger.js';
9
9
  import { ITEM_TYPES } from './validate-item.js';
10
10
  import { commitMove } from './unit-ops.js';
11
+ import { locked } from './main-lock.js';
11
12
 
12
13
  const ALL = ITEM_TYPES;
13
14
  const SOFT = ['mechanic', 'resource'];
@@ -206,8 +207,12 @@ export async function phaseStatus(projectDir) {
206
207
  * The operator moves the phase on, commit `gcf(phase): <from> → <to>`. An unmet criterion needs
207
208
  * `reason`, which the commit carries; the move itself is the operator's word on one that only
208
209
  * the operator can check.
210
+ * @type {typeof nextPhaseUnlocked}
209
211
  */
210
- export async function nextPhase(projectDir, { reason = '', commit = true } = {}) {
212
+ export const nextPhase = (projectDir, opts = {}) =>
213
+ locked(projectDir, opts.commit ?? true, () => nextPhaseUnlocked(projectDir, opts));
214
+
215
+ async function nextPhaseUnlocked(projectDir, { reason = '', commit = true } = {}) {
211
216
  const status = await phaseStatus(projectDir);
212
217
  if (!status.next) throw new Error(`${status.phase} is the last phase`);
213
218
  const unmet = status.criteria.filter((c) => c.met === false);
package/lib/pillars.js CHANGED
@@ -10,6 +10,7 @@ import { recordsIn } from './design-check.js';
10
10
  import { buildLedger } from './ledger.js';
11
11
  import { atomicWrite } from './safe-path.js';
12
12
  import { commitMove } from './unit-ops.js';
13
+ import { locked } from './main-lock.js';
13
14
 
14
15
  const escapeRe = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
15
16
 
@@ -73,7 +74,7 @@ async function revise(projectDir, id, { edit, editPillars, tail, commit }) {
73
74
  }
74
75
 
75
76
  /** `pillar rename <from> <to>`: the id changes in PILLARS.md and every unit and item citing it. */
76
- export async function renamePillar(projectDir, from, to, { commit = true } = {}) {
77
+ async function renamePillarUnlocked(projectDir, from, to, { commit = true } = {}) {
77
78
  if (!SLUG_RE.test(to)) throw new Error(`${to}: a pillar id is a lowercase hyphen slug`);
78
79
  const text = await readFile(path.join(projectDir, 'gcf', 'PILLARS.md'), 'utf8').catch(() => '');
79
80
  if (recordsIn(text, 'pillars').some((p) => p.id === to))
@@ -92,7 +93,7 @@ export async function renamePillar(projectDir, from, to, { commit = true } = {})
92
93
  * unit left citing no pillar reads `pillars: TBD` again — it is not ready until the human gives
93
94
  * it one, or decides it is out of scope. The reason is kept in the commit.
94
95
  */
95
- export async function retirePillar(projectDir, id, { reason, commit = true }) {
96
+ async function retirePillarUnlocked(projectDir, id, { reason, commit = true }) {
96
97
  const why = String(reason ?? '')
97
98
  .replace(/\s+/g, ' ')
98
99
  .trim();
@@ -111,3 +112,11 @@ export async function retirePillar(projectDir, id, { reason, commit = true }) {
111
112
  commit,
112
113
  });
113
114
  }
115
+
116
+ // PILLARS.md and the citing files are read for the edit: the lock holds from that read on.
117
+ /** @type {typeof renamePillarUnlocked} */
118
+ export const renamePillar = (projectDir, from, to, opts = {}) =>
119
+ locked(projectDir, opts.commit ?? true, () => renamePillarUnlocked(projectDir, from, to, opts));
120
+ /** @type {typeof retirePillarUnlocked} */
121
+ export const retirePillar = (projectDir, id, opts) =>
122
+ locked(projectDir, opts.commit ?? true, () => retirePillarUnlocked(projectDir, id, opts));
@@ -26,6 +26,7 @@ const BLOCK = /^([|>])[-+]?\d?[-+]?(\s+#.*)?$/;
26
26
  */
27
27
  function folded(lines) {
28
28
  let text = '';
29
+ /** @type {'text' | 'more' | null} */
29
30
  let prev = null;
30
31
  let blanks = 0;
31
32
  for (const l of lines) {
package/lib/questions.js CHANGED
@@ -7,6 +7,7 @@ import { existsSync } from 'node:fs';
7
7
  import path from 'node:path';
8
8
  import { atomicWrite } from './safe-path.js';
9
9
  import { commitMove } from './unit-ops.js';
10
+ import { locked } from './main-lock.js';
10
11
  import { blockEnd, fieldOf, indentOf } from './question-fields.js';
11
12
 
12
13
  // A D2 carries its assumption and the trigger that brings it back; `lens`, `sections` and `sys`
@@ -93,8 +94,12 @@ function withAnswer(record, answer) {
93
94
  /**
94
95
  * The human answers question `qid` (`gcf question answer`): the answer goes into its record, one
95
96
  * line, and commits as `gcf(answer): <qid>`. Refuses an id the file does not hold.
97
+ * @type {typeof answerUnlocked}
96
98
  */
97
- export async function answerQuestion(projectDir, qid, answer, { commit = true } = {}) {
99
+ export const answerQuestion = (projectDir, qid, answer, opts = {}) =>
100
+ locked(projectDir, opts.commit ?? true, () => answerUnlocked(projectDir, qid, answer, opts));
101
+
102
+ async function answerUnlocked(projectDir, qid, answer, { commit = true } = {}) {
98
103
  const text = String(answer ?? '')
99
104
  .replace(/\s+/g, ' ')
100
105
  .trim();
package/lib/unit-ops.js CHANGED
@@ -16,7 +16,7 @@ import { upsertBlock, openSystemDoc } from './systems-doc.js';
16
16
  import { buryLine, recordLine } from './verdict-fs.js';
17
17
  import { DEFAULT_GATE } from './validate-item.js';
18
18
  import { refuseByPhase } from './phase.js';
19
- import { withMainLock } from './main-lock.js';
19
+ import { locked, withMainLock } from './main-lock.js';
20
20
 
21
21
  /** A design file's text without the `## <id>` section. */
22
22
  export function removeUnit(text, id) {
@@ -265,8 +265,12 @@ async function record(projectDir, message, { files, mutate, commit, inputs = []
265
265
  /**
266
266
  * A hard unit is built and its tests are green: it leaves design/ without a work item. A line
267
267
  * every future reader of the subsystem must remember goes to the systems doc's `constraints:`.
268
+ * @type {typeof doneUnitUnlocked}
268
269
  */
269
- export async function doneUnit(projectDir, id, { constraints = [], commit = true } = {}) {
270
+ export const doneUnit = (projectDir, id, opts = {}) =>
271
+ locked(projectDir, opts.commit ?? true, () => doneUnitUnlocked(projectDir, id, opts));
272
+
273
+ async function doneUnitUnlocked(projectDir, id, { constraints = [], commit = true } = {}) {
270
274
  const root = path.join(path.resolve(projectDir), 'gcf');
271
275
  const unit = await designUnit(projectDir, id);
272
276
  if (CARD_TYPES.includes(unit.header.type))
@@ -300,8 +304,14 @@ export async function addConstraints(doc, id, constraints) {
300
304
  await atomicWrite(doc.file, serialize({ ...doc.data, updated_by: id }, body));
301
305
  }
302
306
 
303
- /** A unit is dropped before any work: a graveyard line keeps the reason (Methodology §9). */
304
- export async function dropUnit(projectDir, id, { reason, tags = [], commit = true }) {
307
+ /**
308
+ * A unit is dropped before any work: a graveyard line keeps the reason (Methodology §9).
309
+ * @type {typeof dropUnitUnlocked}
310
+ */
311
+ export const dropUnit = (projectDir, id, opts) =>
312
+ locked(projectDir, opts.commit ?? true, () => dropUnitUnlocked(projectDir, id, opts));
313
+
314
+ async function dropUnitUnlocked(projectDir, id, { reason, tags = [], commit = true }) {
305
315
  if (!String(reason ?? '').trim())
306
316
  throw new Error('a drop needs a reason: the graveyard keeps it');
307
317
  const root = path.join(path.resolve(projectDir), 'gcf');
package/lib/verify.js CHANGED
@@ -20,7 +20,8 @@ export function runDod(projectDir, config, key, stdio) {
20
20
  ok: false,
21
21
  problems: [`dod.${key} is not set in gcf/config.yml — the project's command, or none`],
22
22
  };
23
- // shortcut: no timeout, a command that waits for input hangs the run; add one when it bites.
23
+ // shortcut: no timeout; stdin is closed, but a command that never ends (a watch mode, a
24
+ // server) hangs `gcf verify`, and a harness run until its own timeout; add one when it bites.
24
25
  const res = spawnSync(cmd, { cwd: projectDir, shell: true, stdio });
25
26
  const ok = res.status === 0;
26
27
  const why = res.error ? res.error.message : `exit ${res.status ?? res.signal}`;
package/lib/work-ops.js CHANGED
@@ -37,7 +37,7 @@ import {
37
37
  } from './live.js';
38
38
  import { dropInbox } from './inbox.js';
39
39
  import { refuseByPhase } from './phase.js';
40
- import { runHolder, withMainLock } from './main-lock.js';
40
+ import { locked, runHolder } from './main-lock.js';
41
41
  import { dropAgentSession, releaseSession, sessionActive } from './session-mark.js';
42
42
 
43
43
  /**
@@ -954,16 +954,6 @@ async function newItemUnlocked(projectDir, spec) {
954
954
  return { item: `gcf/work/${spec.id}.md`, hash };
955
955
  }
956
956
 
957
- /**
958
- * A committed move holds the main lock from its first read to its commit (Methodology §7): one
959
- * that read the ledger before taking it would write over a move committed meanwhile.
960
- * @template T
961
- * @param {string} projectDir
962
- * @param {boolean} commit
963
- * @param {() => Promise<T>} move
964
- */
965
- const locked = (projectDir, commit, move) => (commit ? withMainLock(projectDir, move) : move());
966
-
967
957
  /** @type {typeof submitItemUnlocked} */
968
958
  export const submitItem = (projectDir, id, opts = {}) =>
969
959
  locked(projectDir, opts.commit ?? true, () => submitItemUnlocked(projectDir, id, opts));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gcf-aif-ext",
3
- "version": "0.7.8",
3
+ "version": "0.7.9",
4
4
  "description": "Game Context Framework for AI Factory",
5
5
  "type": "module",
6
6
  "license": "MIT",