@sabaiway/agent-workflow-kit 14.10.0 → 14.12.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.
@@ -0,0 +1,248 @@
1
+ #!/usr/bin/env node
2
+ // tier-preview.mjs — the offer of lineage step 4.0.0: one preview over the whole profile-gap
3
+ // registry; on an explicit yes (--apply) the absent epic and task slots and the epic store seed;
4
+ // on an explicit no (--decline) the decline record. Governing contract:
5
+ // docs/ai/specs/kit/tier/tier-offer/ (part tier-preview). main(argv, deps) returns, never exits,
6
+ // never asks; every read and write goes through deps.
7
+ import { linkSync, lstatSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs';
8
+ import { resolve } from 'node:path';
9
+ import { assertDocsAiDeployment, writeDocsAiFileAtomic, writeProjectFileCreateOnly } from './atomic-write.mjs';
10
+ import { safeLine } from './carriers.mjs';
11
+ import { isDirectRun } from './direct-run.mjs';
12
+ import { tmpNote } from './ensure-ops.mjs';
13
+ import { CANON_README, applySetOps, refreshReadme } from './orchestration-config.mjs';
14
+ import { writeConfig } from './orchestration-write.mjs';
15
+ import { PROFILE_GAPS, readTierConfig } from './profile-gaps.mjs';
16
+ import { composeReadiness, planRecipe } from './recipes.mjs';
17
+ import { TIER_TARGETS, isDeclineCurrent, readDeclines, readShippedProfile } from './reference-profile.mjs';
18
+
19
+ const LF = String.fromCharCode(10);
20
+ const ARGV_OFFSET = 2;
21
+ const EXIT_SUCCESS = 0;
22
+ const EXIT_REFUSAL = 1;
23
+ const EXIT_USAGE = 2;
24
+ const CWD_FLAG = '--cwd';
25
+ const APPLY_FLAG = '--apply';
26
+ const DECLINE_FLAG = '--decline';
27
+ const HELP_FLAGS = new Set(['--help', '-h']);
28
+ const MODE_FLAGS = new Set([APPLY_FLAG, DECLINE_FLAG]);
29
+ const SLOTS_ID = 'epic-task-slots';
30
+ const STORE_ID = 'epic-store-seeded';
31
+ const STORE_DIR = 'docs/ai/epics';
32
+ const STORE_SEED = 'docs/ai/epics/.gitkeep';
33
+ const CONFIG_WRITTEN = 'docs/ai/orchestration.json';
34
+ const DECLINES_REL = 'docs/ai/profile-declines.json';
35
+ const SCHEMA = 1;
36
+ const INDENT = ' ';
37
+ const WORDS = Object.freeze({
38
+ present: 'present',
39
+ offered: 'offered',
40
+ declined: 'declined',
41
+ undecidable: 'undecidable',
42
+ applied: 'applied',
43
+ recorded: 'recorded',
44
+ });
45
+ const ABSENT_VERDICT = 'absent';
46
+ const PRESENT_VERDICT = 'present';
47
+
48
+ export const OUTCOME_LINES = Object.freeze({
49
+ entry: (id, word, detail) => `${id}: ${word}${detail ? ` — ${detail}` : ''}`,
50
+ declinedAt: (lineage) => `at lineage ${lineage}`,
51
+ applyLine: (command) => `${INDENT}apply: ${command}`,
52
+ noCommand: () => `${INDENT}apply: no runnable command — the root or the kit path carries a control byte or a backtick`,
53
+ slotValue: (activity, slot, value, skipped = []) => `${INDENT}${activity}.${slot} = ${value}`
54
+ + skipped.map(({ candidate, reason }) => ` — ${candidate} skipped: ${reason}`).join(''),
55
+ slotPresent: (activity, slot) => `${INDENT}${activity}.${slot}: present`,
56
+ tmpLeft: (tmp) => tmpNote(STORE_SEED, tmp)[0],
57
+ nothingOffered: () => 'nothing offered — nothing recorded',
58
+ detectFailed: (cause) => `backend detection failed (${safeLine(cause)}) — every bridge is treated as not ready`,
59
+ refusal: (name, detail) => `tier-preview: refused — ${name}${detail ? `: ${safeLine(detail)}` : ''}`,
60
+ usage: (problem) => `usage: tier-preview.mjs --cwd DIR [--apply | --decline] — ${safeLine(problem)}`,
61
+ help: () => [
62
+ 'tier-preview.mjs --cwd DIR [--apply | --decline]',
63
+ 'Previews every profile gap of the project in DIR; writes nothing by default.',
64
+ '--apply writes the offered items: the absent epic and task settings and the epic store seed.',
65
+ '--decline records every offered item as declined at the shipped profile lineage.',
66
+ 'Exit codes: 0 done, 1 one named refusal, 2 usage.',
67
+ ].join(LF),
68
+ });
69
+
70
+ const result = (code, stdout = '', stderr = '') => ({ code, stdout, stderr });
71
+ const refuse = (name, detail) => result(EXIT_REFUSAL, '', OUTCOME_LINES.refusal(name, detail));
72
+ const causeOf = (error) => String(error?.message ?? error);
73
+
74
+ const parseArgs = (argv) => {
75
+ if (argv.length === 1 && HELP_FLAGS.has(argv[0])) return { help: true };
76
+ const options = {};
77
+ for (let index = 0; index < argv.length; index += 1) {
78
+ const flag = argv[index];
79
+ if (flag === CWD_FLAG) {
80
+ if (options.cwd !== undefined) return { problem: `${CWD_FLAG} given twice` };
81
+ const value = argv[index + 1];
82
+ if (value === undefined || value.startsWith('-')) return { problem: `${CWD_FLAG} needs a directory` };
83
+ options.cwd = value;
84
+ index += 1;
85
+ } else if (MODE_FLAGS.has(flag)) {
86
+ if (options[flag]) return { problem: `${flag} given twice` };
87
+ options[flag] = true;
88
+ } else {
89
+ return { problem: `unknown argument ${flag}` };
90
+ }
91
+ }
92
+ if (options.cwd === undefined) return { problem: `${CWD_FLAG} is required` };
93
+ if (options[APPLY_FLAG] && options[DECLINE_FLAG]) return { problem: `${APPLY_FLAG} and ${DECLINE_FLAG} exclude each other` };
94
+ return { cwd: options.cwd, apply: Boolean(options[APPLY_FLAG]), decline: Boolean(options[DECLINE_FLAG]) };
95
+ };
96
+
97
+ const withFs = (deps) => ({
98
+ lstatSync, readFileSync, lstat: lstatSync, writeFile: writeFileSync, rename: renameSync, link: linkSync,
99
+ rm: (path) => rmSync(path, { force: true }), mkdir: (path) => mkdirSync(path, { recursive: true }), ...deps,
100
+ });
101
+
102
+ // The resolution walk: the first candidate planRecipe reports not degraded wins; every candidate
103
+ // before it carries that plan's own first degrade reason.
104
+ // solo, every target's last candidate, is always satisfiable, so the find always answers.
105
+ const resolveTarget = ({ candidates }, readiness) => {
106
+ const skipped = [];
107
+ const value = candidates.find((candidate) => {
108
+ const plan = planRecipe(candidate, readiness);
109
+ if (plan.degraded) skipped.push({ candidate, reason: plan.degradation[0].reason });
110
+ return !plan.degraded;
111
+ });
112
+ return { value, skipped };
113
+ };
114
+
115
+ const planSlots = (config, readiness) => TIER_TARGETS.map((target) => {
116
+ const declared = Object.hasOwn(config[target.activity] ?? {}, target.slot);
117
+ return declared ? { target, declared } : { target, declared, ...resolveTarget(target, readiness) };
118
+ });
119
+
120
+ const slotLines = (slots) => slots.map(({ target, declared, value, skipped }) => (declared
121
+ ? OUTCOME_LINES.slotPresent(target.activity, target.slot)
122
+ : OUTCOME_LINES.slotValue(target.activity, target.slot, value, skipped)));
123
+
124
+ const judgeEntries = (root, deps, profile, declined) => PROFILE_GAPS.map((entry) => {
125
+ const detected = entry.detect({ root, deps });
126
+ if (detected.verdict === PRESENT_VERDICT) return { entry, word: WORDS.present };
127
+ if (detected.verdict !== ABSENT_VERDICT) return { entry, word: WORDS.undecidable, detail: detected.reason };
128
+ if (isDeclineCurrent(declined, entry.id, profile.lineage)) {
129
+ return { entry, word: WORDS.declined, detail: OUTCOME_LINES.declinedAt(declined[entry.id]) };
130
+ }
131
+ return { entry, word: WORDS.offered };
132
+ });
133
+
134
+ const renderEntry = ({ entry, word, detail }, root, slots) => {
135
+ const lines = [OUTCOME_LINES.entry(entry.id, word, detail)];
136
+ if (word === WORDS.offered) {
137
+ const command = entry.apply(root);
138
+ lines.push(command ? OUTCOME_LINES.applyLine(command) : OUTCOME_LINES.noCommand());
139
+ }
140
+ if (entry.id === SLOTS_ID && (word === WORDS.offered || word === WORDS.applied)) lines.push(...slotLines(slots));
141
+ return lines;
142
+ };
143
+
144
+ const writeSlots = (root, config, slots, deps) => {
145
+ const ops = slots.filter(({ declared }) => !declared).map(({ target, value }) => ({
146
+ kind: 'set', activity: target.activity, slot: target.slot, recipe: value,
147
+ }));
148
+ const merged = applySetOps(config, ops, { seedReadme: CANON_README });
149
+ writeConfig(root, refreshReadme(merged).config, deps);
150
+ };
151
+
152
+ const storeCreated = (root, deps) => {
153
+ try {
154
+ return deps.lstat(resolve(root, STORE_DIR)).isDirectory();
155
+ } catch {
156
+ return false;
157
+ }
158
+ };
159
+
160
+ const runApply = (root, deps, judged, slots, config) => {
161
+ const byId = new Map(judged.map((item) => [item.entry.id, item]));
162
+ const extra = [];
163
+ const made = [];
164
+ if (byId.get(SLOTS_ID)?.word === WORDS.offered) {
165
+ try {
166
+ writeSlots(root, config, slots, deps);
167
+ } catch (error) {
168
+ return { refusal: refuse('write-failed', causeOf(error)) };
169
+ }
170
+ made.push(`${CONFIG_WRITTEN} written`);
171
+ byId.set(SLOTS_ID, { ...byId.get(SLOTS_ID), word: WORDS.applied });
172
+ }
173
+ if (byId.get(STORE_ID)?.word === WORDS.offered) {
174
+ let seeded;
175
+ try {
176
+ seeded = writeProjectFileCreateOnly(root, STORE_SEED, '', deps, { noun: 'the epic store seed' });
177
+ } catch (error) {
178
+ if (storeCreated(root, deps)) made.push(`${STORE_DIR} created`);
179
+ return { refusal: refuse('write-failed', [...made, causeOf(error)].join('; ')) };
180
+ }
181
+ byId.set(STORE_ID, { ...byId.get(STORE_ID), word: seeded.created ? WORDS.applied : WORDS.present });
182
+ if (seeded.tmpLeftBehind) extra.push(OUTCOME_LINES.tmpLeft(seeded.tmpLeftBehind));
183
+ }
184
+ return { judged: judged.map((item) => byId.get(item.entry.id)), extra };
185
+ };
186
+
187
+ const serializeDeclines = (declined) => {
188
+ const sorted = Object.fromEntries(Object.keys(declined).sort().map((id) => [id, declined[id]]));
189
+ return `${JSON.stringify({ schema: SCHEMA, declined: sorted }, null, 2)}${LF}`;
190
+ };
191
+
192
+ const runDecline = (root, deps, judged, profile, declined) => {
193
+ const offered = judged.filter(({ word }) => word === WORDS.offered);
194
+ if (offered.length === 0) return { judged, extra: [OUTCOME_LINES.nothingOffered()] };
195
+ const merged = { ...(declined ?? {}) };
196
+ for (const { entry } of offered) merged[entry.id] = profile.lineage;
197
+ try {
198
+ writeDocsAiFileAtomic(root, DECLINES_REL, serializeDeclines(merged), deps, { noun: 'the decline record' });
199
+ } catch (error) {
200
+ return { refusal: refuse('write-failed', causeOf(error)) };
201
+ }
202
+ return {
203
+ judged: judged.map((item) => (item.word === WORDS.offered ? { ...item, word: WORDS.recorded } : item)),
204
+ extra: [],
205
+ };
206
+ };
207
+
208
+ const runOffer = (options, given) => {
209
+ const deps = withFs(given);
210
+ const root = resolve(options.cwd);
211
+ try {
212
+ assertDocsAiDeployment(root, deps);
213
+ } catch (error) {
214
+ return refuse('no-deployment', causeOf(error));
215
+ }
216
+ const shipped = readShippedProfile(deps);
217
+ if (shipped.refusal) return refuse(shipped.refusal);
218
+ const record = readDeclines(root, deps);
219
+ if (record.refusal) return refuse(record.refusal);
220
+ const notes = [];
221
+ const readiness = composeReadiness(root, {
222
+ ...deps, onDetectError: (error) => notes.push(OUTCOME_LINES.detectFailed(causeOf(error))),
223
+ });
224
+ const judged = judgeEntries(root, deps, shipped.profile, record.declined);
225
+ const offersSlots = judged.some(({ entry, word }) => entry.id === SLOTS_ID && word === WORDS.offered);
226
+ const config = offersSlots ? readTierConfig(root, deps).config : null;
227
+ const slots = offersSlots ? planSlots(config, readiness) : [];
228
+ let outcome = { judged, extra: [] };
229
+ if (options.apply) outcome = runApply(root, deps, judged, slots, config);
230
+ if (options.decline) outcome = runDecline(root, deps, judged, shipped.profile, record.declined);
231
+ if (outcome.refusal) return outcome.refusal;
232
+ const lines = [...notes, ...outcome.judged.flatMap((item) => renderEntry(item, root, slots)), ...outcome.extra];
233
+ return result(EXIT_SUCCESS, lines.join(LF));
234
+ };
235
+
236
+ export const main = (argv, deps = {}) => {
237
+ const parsed = parseArgs(argv);
238
+ if (parsed.help) return result(EXIT_SUCCESS, OUTCOME_LINES.help());
239
+ if (parsed.problem) return result(EXIT_USAGE, '', OUTCOME_LINES.usage(parsed.problem));
240
+ return runOffer(parsed, deps);
241
+ };
242
+
243
+ if (isDirectRun(import.meta.url)) {
244
+ const outcome = main(process.argv.slice(ARGV_OFFSET));
245
+ if (outcome.stdout) console.log(outcome.stdout);
246
+ if (outcome.stderr) console.error(outcome.stderr);
247
+ process.exitCode = outcome.code;
248
+ }
@@ -87,6 +87,12 @@ export const UPGRADE_RUNLIST = Object.freeze([
87
87
  'STOP',
88
88
  ],
89
89
  ),
90
+ entry(
91
+ 'tier',
92
+ 'node ${CLAUDE_SKILL_DIR}/tools/tier-preview.mjs --cwd <project>',
93
+ 'preview first — on an explicit yes re-run with --apply and run every other offered line with --apply; on an explicit no re-run with --decline',
94
+ ['present', 'offered', 'declined', 'undecidable', 'applied', 'recorded', 'refused'],
95
+ ),
90
96
  entry(
91
97
  'bridge-settings',
92
98
  'node ${CLAUDE_SKILL_DIR}/tools/bridge-settings.mjs --reconcile',
@@ -26,7 +26,7 @@ import { resolveDeclaredDir, dirCovers, isResolvableDeclaredEntry } from './decl
26
26
 
27
27
  // Deployment-lineage head this velocity build targets; bump together with agent-workflow-memory
28
28
  // LINEAGE_HEAD when the deployed docs/ai structure changes.
29
- export const EXPECTED_WORKFLOW_VERSION = '3.0.0';
29
+ export const EXPECTED_WORKFLOW_VERSION = '4.0.0';
30
30
  export const SETTINGS_FILE = '.claude/settings.json';
31
31
  export const SETTINGS_LOCAL_FILE = '.claude/settings.local.json';
32
32
  export const CLAUDE_DIR = '.claude';