gcf-aif-ext 0.8.0 → 0.10.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/commands/gcf.js CHANGED
@@ -1106,12 +1106,18 @@ function registerBoard(gcf) {
1106
1106
  const tag = (i) =>
1107
1107
  `${i.id}${i.plan?.tasks !== undefined ? ` ${i.plan.done}/${i.plan.tasks}` : ''}` +
1108
1108
  `${i.running ? ' (running)' : ''}${i.verified ? ' (verified)' : ''}`;
1109
- const byId = new Map(r.items.map((i) => [i.id, i]));
1110
1109
  out(o.json, r, (x) =>
1111
1110
  [
1112
1111
  `phase: ${x.phase.phase ?? x.phase.error}`,
1113
- ...Object.entries(x.columns).map(
1114
- ([s, ids]) => `${s}: ${ids.map((id) => tag(byId.get(id))).join(', ') || '-'}`,
1112
+ // Items come in run order, so each column lists its own in that order.
1113
+ ...x.domain.columns.map(
1114
+ ({ status }) =>
1115
+ `${status}: ${
1116
+ x.items
1117
+ .filter((i) => i.status === status)
1118
+ .map(tag)
1119
+ .join(', ') || '-'
1120
+ }`,
1115
1121
  ),
1116
1122
  `wip: ${x.wip.count}/${x.wip.limit}`,
1117
1123
  `inbox: ${x.inbox.length}, open questions: ${x.questions.length}`,
@@ -1124,14 +1130,14 @@ function registerBoard(gcf) {
1124
1130
  function registerSettings(gcf) {
1125
1131
  const config = gcf
1126
1132
  .command('config')
1127
- .description("The human's settings of gcf/config.yml: list them, or set one (commit)")
1133
+ .description("The human's settings and the agents' language: list them, or set one (commit)")
1128
1134
  // A misspelt subcommand would read as a stray word and list the settings, as if set.
1129
1135
  .allowExcessArguments(false)
1130
1136
  .option('--json', 'machine output')
1131
1137
  .action(async (o) => {
1132
- const { gcfRoot, readConfig, readRawConfig } = await lib('gcf-paths');
1138
+ const { gcfRoot, readConfig } = await lib('gcf-paths');
1133
1139
  await readConfig(gcfRoot()); // a config the rules refuse says so here
1134
- const list = (await lib('config-set')).settingsOf(await readRawConfig(gcfRoot()));
1140
+ const list = await (await lib('config-set')).projectSettings(process.cwd());
1135
1141
  out(o.json, list, (x) =>
1136
1142
  x.map((s) => `${s.key}: ${s.value ?? '-'} # ${s.label}`).join('\n'),
1137
1143
  );
@@ -1151,7 +1157,11 @@ function registerSettings(gcf) {
1151
1157
  commit: o.commit,
1152
1158
  });
1153
1159
  const now = `${r.key}: ${r.value ?? 'default'}`;
1154
- console.log(r.unchanged ? `${now} (unchanged)` : `${now}${shortHash(r.hash)}`);
1160
+ // An ignored file is written but not committed: a worktree's agent does not read it.
1161
+ const note = r.ignored
1162
+ ? ' (git ignores the file: not committed, and agents in a worktree will not see it)'
1163
+ : '';
1164
+ console.log(r.unchanged ? `${now} (unchanged)` : `${now}${shortHash(r.hash)}${note}`);
1155
1165
  });
1156
1166
  const many = (v, prev = []) => [...prev, v];
1157
1167
  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.8.0",
4
+ "version": "0.10.0",
5
5
  "description": "Game Context Framework for AI Factory",
6
6
  "skills": [
7
7
  "skills/gcf-init",
package/lib/aif-config.js CHANGED
@@ -1,22 +1,40 @@
1
1
  // The language AI Factory sets in `.ai-factory/config.yaml` for what an agent writes to the
2
2
  // human: `language.ui`. GCF adds no setting of its own (Methodology §5.2.3); agents read the
3
- // file themselves, the core reads it only for the prompts it writes.
3
+ // file themselves, the core reads it for the prompts it writes and sets it for the board
4
+ // (`gcf config set language.ui`).
4
5
  import { readFile } from 'node:fs/promises';
5
6
  import path from 'node:path';
6
7
  import YAML from 'yaml';
7
8
 
9
+ /** AI Factory's config file, project-relative. */
10
+ export const AIF_CONFIG = '.ai-factory/config.yaml';
11
+
12
+ // A language name or code, one word in any script: anything else would break the prompt line it
13
+ // goes in.
14
+ export const LANGUAGE_RE = /^[\p{L}\p{N}_-]{2,32}$/u;
15
+
8
16
  /**
9
- * The project's `language.ui`, `en` by default. A missing or unreadable file gives `en`: the
10
- * language is a preference, and a broken one must not stop the work.
17
+ * `.ai-factory/config.yaml` as the file has it: `{}` when it is missing, unreadable or not a
18
+ * map. The language is a preference, and a broken file must not stop the work.
19
+ * @returns {Promise<Record<string, any>>}
11
20
  */
12
- export async function uiLanguage(projectDir) {
21
+ export async function readAifConfig(projectDir) {
13
22
  try {
14
- const file = path.join(path.resolve(projectDir), '.ai-factory', 'config.yaml');
15
- const ui = YAML.parse(await readFile(file, 'utf8'))?.language?.ui;
23
+ const raw = YAML.parse(await readFile(path.join(path.resolve(projectDir), AIF_CONFIG), 'utf8'));
16
24
 
17
- // A language name or code, one word: anything else would break the prompt line it goes in.
18
- return typeof ui === 'string' && /^[\w-]{2,32}$/.test(ui.trim()) ? ui.trim() : 'en';
25
+ return raw && typeof raw === 'object' && !Array.isArray(raw) ? raw : {};
19
26
  } catch {
20
- return 'en';
27
+ return {};
21
28
  }
22
29
  }
30
+
31
+ /** A language of the parsed file (`ui`, `artifacts`), or null when it holds no language. */
32
+ export function languageIn(aif, key) {
33
+ const v = aif?.language?.[key];
34
+
35
+ return typeof v === 'string' && LANGUAGE_RE.test(v.trim()) ? v.trim() : null;
36
+ }
37
+
38
+ /** The project's `language.ui`, `en` by default or when the value is no language. */
39
+ export const uiLanguage = async (projectDir) =>
40
+ languageIn(await readAifConfig(projectDir), 'ui') ?? 'en';
package/lib/board.js CHANGED
@@ -5,7 +5,7 @@ import { readFile } from 'node:fs/promises';
5
5
  import { existsSync } from 'node:fs';
6
6
  import path from 'node:path';
7
7
  import { buildLedger } from './ledger.js';
8
- import { readConfig, readRawConfig } from './gcf-paths.js';
8
+ import { readConfig } from './gcf-paths.js';
9
9
  import { readLive } from './live.js';
10
10
  import { listInbox } from './inbox.js';
11
11
  import { phaseStatus } from './phase.js';
@@ -15,17 +15,16 @@ import { wipOf } from './audit-core.js';
15
15
  import { runHolder } from './main-lock.js';
16
16
  import { ITEM_TYPES } from './validate-item.js';
17
17
  import { movesOf } from './transitions.js';
18
+ import { domainOf, hasHarness, RUNS_FROM } from './domain.js';
18
19
  import { openDepends } from './work-ops.js';
19
20
  import { listQuestions } from './questions.js';
20
21
  import { sessionActive } from './session-mark.js';
21
22
  import { agentOf } from './agent-permissions.js';
22
- import { settingsOf } from './config-set.js';
23
+ import { projectSettings } from './config-set.js';
23
24
 
24
25
  // The version of the JSON below (Methodology §11): a board refuses one it does not know. Bump it
25
26
  // when a field changes meaning or goes away; a new field does not bump it.
26
- export const BOARD_SCHEMA = 1;
27
-
28
- const COLUMNS = ['ready', 'blocked', 'awaiting-verdict', 'awaiting-decision', 'unplayed', 'parked'];
27
+ export const BOARD_SCHEMA = 3;
29
28
 
30
29
  /**
31
30
  * Plan progress `{tasks, done}` from its checkboxes and the plan's project path, both null
@@ -232,8 +231,19 @@ function runningKind(projectDir, id) {
232
231
  return sessionActive(projectDir, id) ? 'session' : null;
233
232
  }
234
233
 
234
+ /**
235
+ * What a board may offer (§4.1): nothing while a run drives the item; with the harness, the
236
+ * agent's run to an item that waits for one and has no green verify yet.
237
+ */
238
+ function offered(c, { running, verified, harness }) {
239
+ if (running) return [];
240
+ const runs = harness && c.data.status === RUNS_FROM && !verified;
241
+
242
+ return [...(runs ? ['run'] : []), ...movesOf(c.data)];
243
+ }
244
+
235
245
  /** One item of the board. */
236
- async function itemOf(projectDir, c, verified, ledger) {
246
+ async function itemOf(projectDir, c, { verified, ledger, harness }) {
237
247
  const id = String(c.data.id);
238
248
  // A trailer counts only while the item waits on main for its integrate.
239
249
  const ready = c.data.status === 'ready' && verified.has(id);
@@ -265,11 +275,13 @@ async function itemOf(projectDir, c, verified, ledger) {
265
275
  resumable,
266
276
  resume,
267
277
  verified: ready,
268
- // The dependencies the run gate would refuse a ready item on now (Board-PRD §5.2).
278
+ // The dependencies the run gate would refuse a ready item on now (Board-PRD §5.2); none
279
+ // while it runs, past the gate.
269
280
  waits_for:
270
- c.data.status === 'ready' ? openDepends(ledger, c.data.depends ?? [], { waiting: true }) : [],
271
- // What a board may offer (§4.1): nothing while a run drives the item.
272
- moves: running ? [] : movesOf(c.data),
281
+ c.data.status === 'ready' && !running
282
+ ? openDepends(ledger, c.data.depends ?? [], { waiting: true })
283
+ : [],
284
+ moves: offered(c, { running, verified: ready, harness }),
273
285
  file: `gcf/work/${c.name}`,
274
286
  log: logOf(projectDir, id),
275
287
  // Filled by board() from the inbox.
@@ -282,8 +294,11 @@ export async function board(projectDir) {
282
294
  const root = path.join(path.resolve(projectDir), 'gcf');
283
295
  const ledger = await buildLedger(projectDir);
284
296
  const verified = verifiedOf(projectDir);
297
+ const extensions = await extensionsOf(projectDir);
298
+ const harness = hasHarness(extensions);
285
299
  const items = [];
286
- for (const c of ledger.items) items.push(await itemOf(projectDir, c, verified, ledger));
300
+ for (const c of ledger.items)
301
+ items.push(await itemOf(projectDir, c, { verified, ledger, harness }));
287
302
  inRunOrder(items);
288
303
  // When the wait began: the time of the commit `since` names (Board-PRD §13).
289
304
  const times = commitTimes(
@@ -306,20 +321,20 @@ export async function board(projectDir) {
306
321
  run_mode: config.run_mode,
307
322
  // What the harness hands its agent runs (Harness-PRD §7): deny rules and the context limits.
308
323
  agent: agentOf(config),
309
- // The human's settings of config.yml, each with its value and default (`gcf config set`).
310
- settings: settingsOf(await readRawConfig(root)),
324
+ // The human's settings of config.yml and the agents' language, each with its value and
325
+ // default (`gcf config set`).
326
+ settings: await projectSettings(projectDir),
311
327
  context: {
312
328
  handoff_pct: config.context_handoff_pct,
313
329
  window: config.context_window,
314
330
  compact_pct: config.auto_compact_pct,
315
331
  },
316
332
  phase: await phaseOf(projectDir),
317
- extensions: await extensionsOf(projectDir),
333
+ extensions,
318
334
  // A malformed block reads with fields missing (live check names it): null, not absent.
319
335
  release: releaseOf(live.release),
320
- columns: Object.fromEntries(
321
- COLUMNS.map((s) => [s, items.filter((i) => i.status === s).map((i) => i.id)]),
322
- ),
336
+ // The domain as data (Board-PRD decision 16): its columns, blocks, fields, moves and terms.
337
+ domain: domainOf(extensions),
323
338
  items,
324
339
  wip: wipOf(ledger.items, config),
325
340
  inbox,
package/lib/config-set.js CHANGED
@@ -1,25 +1,36 @@
1
1
  // `gcf config set` (GCF-PRD §12.4): the human's settings of gcf/config.yml, one key at a time,
2
2
  // so a board edits them through the core and never writes the file itself. A change is checked
3
3
  // by the same rules a read applies before it is written; the rest of the file keeps its keys
4
- // and comments. `phases:` and `agent:` have their own commands and are not set here.
4
+ // and comments. `phases:` and `agent:` have their own commands and are not set here. The
5
+ // language agents write in is AI Factory's (`.ai-factory/config.yaml`), set here for the board
6
+ // too, since AI Factory has no command for it (Board-PRD decision 12).
5
7
  import { readFile } from 'node:fs/promises';
6
8
  import { existsSync } from 'node:fs';
9
+ import { spawnSync } from 'node:child_process';
7
10
  import path from 'node:path';
8
11
  import { isDeepStrictEqual } from 'node:util';
9
12
  import YAML from 'yaml';
10
13
  import { commitMove } from './unit-ops.js';
14
+ import { withMainLock } from './main-lock.js';
11
15
  import { atomicWrite } from './safe-path.js';
12
- import { configOf, DEFAULT_CONFIG, gcfRoot, RUN_MODES } from './gcf-paths.js';
16
+ import { configOf, DEFAULT_CONFIG, gcfRoot, readRawConfig, RUN_MODES } from './gcf-paths.js';
17
+ import { AIF_CONFIG, LANGUAGE_RE, languageIn, readAifConfig } from './aif-config.js';
13
18
 
14
- /** @typedef {'number' | 'share' | 'mode' | 'command'} Kind */
19
+ /** @typedef {'number' | 'share' | 'mode' | 'command' | 'language'} Kind */
15
20
 
16
21
  // Thrown inside the move to leave without a write or a commit.
17
22
  const NO_CHANGE = new Error('no change');
18
23
 
19
- /** gcf/config.yml as a YAML document with a map on top: an edit keeps its comments. */
20
- export function configDoc(text) {
24
+ // The languages AI Factory's own setup offers; any other name or code is taken too.
25
+ const LANGUAGES = ['en', 'ru', 'de', 'fr', 'es', 'zh', 'ja', 'ko', 'pt', 'it'];
26
+
27
+ /** A YAML file as a document with a map on top: an edit keeps its comments. */
28
+ export function configDoc(text, name = 'gcf/config.yml') {
21
29
  const doc = YAML.parseDocument(text);
22
- if (doc.errors.length) throw new Error(`gcf/config.yml: ${doc.errors[0].message}`);
30
+ if (doc.errors.length) throw new Error(`${name}: ${doc.errors[0].message}`);
31
+ // A file of a list or a word on top is not a settings file: replacing it would lose it.
32
+ if (doc.contents != null && !YAML.isMap(doc.contents) && doc.contents.toJSON() != null)
33
+ throw new Error(`${name} must map its keys`);
23
34
  if (!YAML.isMap(doc.contents)) doc.contents = /** @type {any} */ (doc.createNode({}));
24
35
 
25
36
  return doc;
@@ -33,8 +44,24 @@ export function ensureMap(doc, keys, error) {
33
44
  doc.setIn(keys, doc.createNode({}));
34
45
  }
35
46
 
36
- /** @type {{ key: string, kind: Kind, label: string }[]} */
47
+ /** @type {{ key: string, kind: Kind, label: string, file?: string, options?: string[] }[]} */
37
48
  export const SETTINGS = [
49
+ {
50
+ key: 'language.ui',
51
+ kind: 'language',
52
+ file: AIF_CONFIG,
53
+ options: LANGUAGES,
54
+ label:
55
+ 'the language agents write to you in: questions, block reasons, inbox entries, lessons, ' +
56
+ 'titles',
57
+ },
58
+ {
59
+ key: 'language.artifacts',
60
+ kind: 'language',
61
+ file: AIF_CONFIG,
62
+ options: LANGUAGES,
63
+ label: 'the language of plans and of the bodies of items that are not design units',
64
+ },
38
65
  {
39
66
  key: 'run_mode',
40
67
  kind: 'mode',
@@ -95,28 +122,54 @@ export const SETTINGS = [
95
122
 
96
123
  const BY_KEY = new Map(SETTINGS.map((s) => [s.key, s]));
97
124
 
125
+ /** The default a setting holds while its file does not set it; the artifacts follow the ui. */
126
+ function defaultOf(s, aif) {
127
+ const [head, sub] = s.key.split('.');
128
+ if (s.key === 'language.artifacts') return languageIn(aif, 'ui') ?? 'en';
129
+ if (s.kind === 'language') return 'en';
130
+ if (sub) return null;
131
+
132
+ return head === 'run_mode' ? 'worktree' : (DEFAULT_CONFIG[head] ?? null);
133
+ }
134
+
98
135
  /**
99
- * Each setting with what the file sets (null: not set, the default holds) and its default:
100
- * what a settings screen shows. `raw` is the parsed config.yml, before the defaults.
136
+ * Each setting with what its file sets (null: not set, the default holds) and its default:
137
+ * what a settings screen shows. `raw` is the parsed config.yml, before the defaults; `aif` the
138
+ * parsed `.ai-factory/config.yaml`.
101
139
  */
102
- export function settingsOf(raw) {
140
+ export function settingsOf(raw, aif = {}) {
103
141
  return SETTINGS.map((s) => {
104
142
  const [head, sub] = s.key.split('.');
105
- const parent = raw?.[head];
143
+ const parent = (s.file ? aif : raw)?.[head];
106
144
  const value = sub
107
145
  ? parent && typeof parent === 'object'
108
146
  ? (parent[sub] ?? null)
109
147
  : null
110
148
  : (parent ?? null);
111
- const def = sub ? null : head === 'run_mode' ? 'worktree' : (DEFAULT_CONFIG[head] ?? null);
149
+ // AI Factory's file is not checked by GCF's rules: a value that is no language shows unset.
150
+ const shown = s.file ? languageIn(aif, sub) : value;
112
151
 
113
- return { ...s, value, default: def };
152
+ return { ...s, value: shown, default: defaultOf(s, aif) };
114
153
  });
115
154
  }
116
155
 
156
+ /** Every setting as the project's two files have them. */
157
+ export const projectSettings = async (projectDir) =>
158
+ settingsOf(await readRawConfig(gcfRoot(projectDir)), await readAifConfig(projectDir));
159
+
160
+ /** A language name or code; empty takes the key off, and AI Factory's default holds. */
161
+ function languageOf(setting, t) {
162
+ if (t && !LANGUAGE_RE.test(t))
163
+ throw new Error(`${setting.key} must be a language name or code (en, ru…), got ${t}`);
164
+
165
+ return t || null;
166
+ }
167
+
117
168
  /** The value of `text` for a setting of `kind`; an empty number goes back to its default. */
118
169
  function valueOf(setting, text) {
119
170
  const t = String(text ?? '').trim();
171
+ if (setting.kind === 'language') return languageOf(setting, t);
172
+
120
173
  if (setting.kind === 'command') {
121
174
  if (!t) throw new Error(`${setting.key}: name the command, or none`);
122
175
 
@@ -138,45 +191,71 @@ function valueOf(setting, text) {
138
191
  return n;
139
192
  }
140
193
 
194
+ /** Whether git ignores `file`: a commit cannot carry it, and a worktree does not have it. */
195
+ const ignoredByGit = (projectDir, file) =>
196
+ spawnSync('git', ['check-ignore', '-q', '--', file], { cwd: projectDir }).status === 0;
197
+
198
+ const trackedByGit = (projectDir, file) =>
199
+ spawnSync('git', ['ls-files', '--error-unmatch', '--', file], { cwd: projectDir }).status === 0;
200
+
141
201
  /**
142
- * Set one key of gcf/config.yml and commit gcf(config); a number set to nothing goes back to
143
- * its default. The file is read and checked under the move's lock.
202
+ * Set one key of gcf/config.yml, or the language of `.ai-factory/config.yaml`, and commit
203
+ * gcf(config); a number set to nothing goes back to its default. The file is read and checked
204
+ * under the move's lock. An AI Factory file git ignores is written, not committed (`ignored`).
144
205
  */
145
206
  export async function setConfig(projectDir, key, text, { commit = true } = {}) {
146
207
  const setting = BY_KEY.get(key);
147
208
  if (!setting) throw new Error(`no setting ${key}: ${SETTINGS.map((s) => s.key).join(' | ')}`);
148
209
  const value = valueOf(setting, text);
149
- const file = path.join(gcfRoot(projectDir), 'config.yml');
210
+ const name = setting.file ?? 'gcf/config.yml';
211
+ const file = setting.file
212
+ ? path.join(path.resolve(projectDir), setting.file)
213
+ : path.join(gcfRoot(projectDir), 'config.yml');
214
+ const ignored = Boolean(setting.file) && ignoredByGit(projectDir, file);
215
+ // AI Factory's setup may leave its file uncommitted: the first change commits it whole.
216
+ const inputs = setting.file && !ignored && !trackedByGit(projectDir, file) ? [file] : [];
150
217
  const keys = key.split('.');
151
- let raw;
152
218
  // A change that changes nothing (the same value, a reset of a key not set) commits nothing.
153
219
  let unchanged = false;
154
- const hash = await commitMove(projectDir, `gcf(config): ${key}`, {
155
- files: [file],
156
- mutate: async () => {
157
- const text = existsSync(file) ? await readFile(file, 'utf8') : '';
158
- const doc = configDoc(text);
159
- if (keys.length > 1)
160
- ensureMap(doc, [keys[0]], `gcf/config.yml: ${keys[0]} must map its keys`);
161
- if (value === null) doc.deleteIn(keys);
162
- else doc.setIn(keys, value);
163
- raw = doc.toJS();
164
- configOf(raw);
165
- const out = doc.toString();
166
- // The values, not the text: YAML's output may differ in form (a final newline, `{}`).
167
- if (isDeepStrictEqual(YAML.parse(text) ?? {}, raw ?? {})) {
168
- unchanged = true;
169
- throw NO_CHANGE;
170
- }
171
-
172
- await atomicWrite(file, out);
173
- },
174
- commit,
175
- }).catch((e) => {
220
+ const move = () =>
221
+ commitMove(projectDir, `gcf(config): ${key}`, {
222
+ files: [file],
223
+ inputs,
224
+ mutate,
225
+ commit: commit && !ignored,
226
+ });
227
+ const mutate = async () => {
228
+ const text = existsSync(file) ? await readFile(file, 'utf8') : '';
229
+ const doc = configDoc(text, name);
230
+ // A reset of a key whose parent is not there changes nothing: no empty map is written.
231
+ if (value === null && keys.length > 1 && !YAML.isMap(doc.getIn([keys[0]], true))) {
232
+ unchanged = true;
233
+ throw NO_CHANGE;
234
+ }
235
+
236
+ if (keys.length > 1) ensureMap(doc, [keys[0]], `${name}: ${keys[0]} must map its keys`);
237
+ if (value === null) doc.deleteIn(keys);
238
+ else doc.setIn(keys, value);
239
+ const raw = doc.toJS();
240
+ if (!setting.file) configOf(raw);
241
+ const out = doc.toString();
242
+ // The values, not the text: YAML's output may differ in form (a final newline, `{}`).
243
+ if (isDeepStrictEqual(YAML.parse(text) ?? {}, raw ?? {})) {
244
+ unchanged = true;
245
+ throw NO_CHANGE;
246
+ }
247
+
248
+ await atomicWrite(file, out);
249
+ };
250
+
251
+ // A move that commits nothing takes no lock in commitMove: the ignored file still needs one.
252
+ const hash = await (ignored && commit ? withMainLock(projectDir, move) : move()).catch((e) => {
176
253
  if (e !== NO_CHANGE) throw e;
177
254
 
178
255
  return null;
179
256
  });
257
+ // The change is made: a broken gcf/config.yml must not read as its failure.
258
+ const settings = await projectSettings(projectDir).catch(() => []);
180
259
 
181
- return { key, value, unchanged, settings: settingsOf(raw), hash };
260
+ return { key, value, unchanged, ignored, settings, hash };
182
261
  }