@haposoft/cafekit 0.18.0 → 0.18.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/bin/__tests__/codex-hooks.test.js +169 -236
- package/bin/__tests__/codex-native.test.js +54 -114
- package/bin/__tests__/develop-contract.test.js +101 -1361
- package/bin/__tests__/legacy-authority-decoupled.test.js +14 -19
- package/bin/__tests__/package-inventory.test.js +1 -395
- package/bin/__tests__/spec-narrowing.test.js +30 -134
- package/bin/__tests__/specs-v2-execution-closeout.test.js +0 -372
- package/bin/__tests__/test-runtime-dependency-closure.cjs +1 -2
- package/bin/__tests__/validator-grounding.test.js +17 -2929
- package/bin/lib/managed-writer.js +2 -3
- package/bin/phases/codex-runtime.js +6 -3
- package/bin/phases/copy-payload.js +13 -28
- package/bin/phases/omp-runtime.js +6 -3
- package/package.json +2 -3
- package/src/claude/agents/code-auditor.md +9 -6
- package/src/claude/agents/debugger.md +3 -9
- package/src/claude/agents/docs-keeper.md +4 -7
- package/src/claude/agents/git-ops.md +2 -4
- package/src/claude/agents/implementer.md +6 -7
- package/src/claude/agents/inspector.md +2 -0
- package/src/claude/agents/project-manager.md +2 -2
- package/src/claude/agents/spec-maker.md +4 -4
- package/src/claude/agents/test-runner.md +8 -11
- package/src/claude/agents/ui-ux-designer.md +0 -1
- package/src/claude/hooks/__tests__/spec-gate.test.js +170 -330
- package/src/claude/hooks/__tests__/spec-state-touched.test.js +26 -0
- package/src/claude/hooks/spec-gate.cjs +8 -63
- package/src/claude/hooks/spec-state.cjs +47 -38
- package/src/claude/migration-manifest.json +12 -7
- package/src/claude/output-styles/coding-level-0-eli5.md +9 -9
- package/src/claude/output-styles/coding-level-1-junior.md +3 -3
- package/src/claude/output-styles/coding-level-2-mid.md +3 -3
- package/src/claude/output-styles/coding-level-3-senior.md +2 -2
- package/src/claude/output-styles/coding-level-4-lead.md +5 -5
- package/src/claude/output-styles/coding-level-5-god.md +6 -6
- package/src/claude/rules/ai-dev-rules.md +1 -2
- package/src/claude/rules/workflow.md +1 -1
- package/src/claude/scripts/spec-receipt.cjs +0 -114
- package/src/claude/scripts/spec-resolver.cjs +41 -457
- package/src/claude/scripts/workflow-policy.cjs +3 -1801
- package/src/claude/skills/agent-browser/SKILL.md +1 -1
- package/src/claude/skills/chrome-devtools/SKILL.md +14 -31
- package/src/claude/skills/code-review/SKILL.md +25 -108
- package/src/claude/skills/code-review/references/spec-compliance-review.md +4 -7
- package/src/claude/skills/develop/SKILL.md +22 -70
- package/src/claude/skills/develop/references/subagent-patterns.md +0 -6
- package/src/claude/skills/research/SKILL.md +24 -55
- package/src/claude/skills/sync/SKILL.md +12 -34
- package/src/claude/skills/sync/references/rebind-and-audit.md +2 -2
- package/src/claude/skills/test/SKILL.md +39 -123
- package/src/claude/skills/test/references/execution-strategy.md +11 -22
- package/src/claude/skills/ui-ux/SKILL.md +1 -1
- package/src/claude/skills/ui-ux-pro-max/SKILL.md +24 -79
- package/src/claude/skills/web-testing/SKILL.md +2 -20
- package/src/codex/hooks/lib/spec-receipt.cjs +2 -30
- package/src/codex/hooks/lib/spec-utils.cjs +6 -45
- package/src/codex/hooks/spec-gate.cjs +11 -68
- package/src/codex/hooks/spec-state.cjs +37 -41
- package/src/common/AGENTS.md +4 -10
- package/bin/__tests__/change-firewall.semantic-firewall.test.js +0 -2133
- package/bin/__tests__/finalizer-pass-gate.semantic-firewall.test.js +0 -342
- package/bin/__tests__/release-preflight.semantic-firewall.test.js +0 -586
- package/bin/__tests__/review-receipt-c2.semantic-firewall.test.js +0 -378
- package/bin/__tests__/specs-v2-policy-and-scaffold.test.js +0 -665
- package/bin/__tests__/specs-v2-validator-grounder-contract.test.js +0 -1132
- package/src/claude/hooks/lib/context.cjs +0 -527
- package/src/claude/hooks/lib/detect.cjs +0 -474
- package/src/claude/scripts/change-firewall.cjs +0 -1748
- package/src/claude/scripts/spec-authoring-digest.cjs +0 -151
- package/src/claude/scripts/spec-authoring-validation.cjs +0 -126
- package/src/claude/scripts/spec-final-state.cjs +0 -290
- package/src/claude/scripts/spec-ground.cjs +0 -650
- package/src/claude/scripts/spec-readiness.cjs +0 -425
- package/src/claude/scripts/spec-scaffold.cjs +0 -2317
- package/src/claude/scripts/spec-semantic-model.cjs +0 -396
- package/src/claude/scripts/validate-spec-output.cjs +0 -3914
- package/src/claude/skills/code-review/references/verification-gate.md +0 -41
- package/src/claude/skills/specs/templates/design.md +0 -82
- package/src/claude/skills/specs/templates/requirements-init.md +0 -8
- package/src/claude/skills/specs/templates/requirements.md +0 -42
- package/src/claude/skills/specs/templates/spec-state.json +0 -56
- package/src/claude/skills/specs/templates/task.md +0 -47
|
@@ -58,10 +58,6 @@ function assertSpecsRootContained(projectRoot, specsDir) {
|
|
|
58
58
|
}
|
|
59
59
|
}
|
|
60
60
|
|
|
61
|
-
function isPlainObject(value) {
|
|
62
|
-
return Boolean(value && typeof value === 'object' && !Array.isArray(value));
|
|
63
|
-
}
|
|
64
|
-
|
|
65
61
|
function candidateError(reason, code = 'malformed') {
|
|
66
62
|
const error = new Error(reason);
|
|
67
63
|
error.code = code;
|
|
@@ -221,10 +217,6 @@ function inspectWorkflowFeature(specsDir, canonicalSpecs, requestedName) {
|
|
|
221
217
|
}
|
|
222
218
|
|
|
223
219
|
const featureName = path.basename(canonicalFeature);
|
|
224
|
-
if (fs.existsSync(path.join(canonicalFeature, 'spec.json'))) {
|
|
225
|
-
return { legacyPresent: true, featureName, featureDir: canonicalFeature };
|
|
226
|
-
}
|
|
227
|
-
|
|
228
220
|
const planFile = path.join(canonicalFeature, 'plan.md');
|
|
229
221
|
let planLstat;
|
|
230
222
|
try {
|
|
@@ -289,210 +281,6 @@ function inspectWorkflowFeature(specsDir, canonicalSpecs, requestedName) {
|
|
|
289
281
|
};
|
|
290
282
|
}
|
|
291
283
|
|
|
292
|
-
// Legacy registries written before the current vocabulary use `completed`, which today's
|
|
293
|
-
// validator rejects but which exists in upgraded repositories. Reading it as finished is
|
|
294
|
-
// what lets those packets stop competing for the gate's attention; `spec-final-state.cjs`
|
|
295
|
-
// already reads spec status the same way. Process-first packets keep the strict set.
|
|
296
|
-
const LEGACY_TASK_DONE = new Set(['done', 'completed', 'complete']);
|
|
297
|
-
|
|
298
|
-
function normalizeLegacyWorkflowCandidate(candidate) {
|
|
299
|
-
const registry = candidate.spec?.task_registry || {};
|
|
300
|
-
const tasks = Object.entries(registry).map(([taskPath, task]) => ({
|
|
301
|
-
path: taskPath,
|
|
302
|
-
status: task?.status || 'pending',
|
|
303
|
-
}));
|
|
304
|
-
return {
|
|
305
|
-
...candidate,
|
|
306
|
-
layoutKind: 'legacy-spec',
|
|
307
|
-
stateFile: candidate.specFile,
|
|
308
|
-
phase: candidate.spec?.current_phase || candidate.spec?.phase || 'unknown',
|
|
309
|
-
taskRegistry: registry,
|
|
310
|
-
tasks,
|
|
311
|
-
allTasksDone: tasks.length > 0 && tasks.every((task) => LEGACY_TASK_DONE.has(task.status)),
|
|
312
|
-
};
|
|
313
|
-
}
|
|
314
|
-
|
|
315
|
-
function inspectFeature(specsDir, canonicalSpecs, requestedName) {
|
|
316
|
-
const featureDir = path.join(specsDir, requestedName);
|
|
317
|
-
let featureLstat;
|
|
318
|
-
try {
|
|
319
|
-
featureLstat = fs.lstatSync(featureDir);
|
|
320
|
-
} catch (e) {
|
|
321
|
-
if (isMissingError(e)) return { missing: true };
|
|
322
|
-
throw candidateError(`feature lstat error: ${e.message}`);
|
|
323
|
-
}
|
|
324
|
-
|
|
325
|
-
let canonicalFeature;
|
|
326
|
-
try {
|
|
327
|
-
canonicalFeature = fs.realpathSync(featureDir);
|
|
328
|
-
if (!fs.statSync(featureDir).isDirectory()) {
|
|
329
|
-
throw candidateError('feature entry must be a directory');
|
|
330
|
-
}
|
|
331
|
-
} catch (e) {
|
|
332
|
-
if (e && e.code) throw e;
|
|
333
|
-
throw candidateError(`feature canonicalization error: ${e.message}`);
|
|
334
|
-
}
|
|
335
|
-
if (canonicalFeature !== canonicalSpecs && !isPathInside(canonicalSpecs, canonicalFeature)) {
|
|
336
|
-
throw candidateError('feature symlink escapes specs root');
|
|
337
|
-
}
|
|
338
|
-
if (path.dirname(canonicalFeature) !== canonicalSpecs) {
|
|
339
|
-
throw candidateError('feature directory must resolve to a direct child of specs root');
|
|
340
|
-
}
|
|
341
|
-
|
|
342
|
-
const featureName = path.basename(canonicalFeature);
|
|
343
|
-
const specFile = path.join(featureDir, 'spec.json');
|
|
344
|
-
let specLstat;
|
|
345
|
-
try {
|
|
346
|
-
specLstat = fs.lstatSync(specFile);
|
|
347
|
-
} catch (e) {
|
|
348
|
-
if (isMissingError(e)) return { missingSpec: true, featureName, featureDir, canonicalFeature, specFile };
|
|
349
|
-
throw candidateError(`spec.json lstat error: ${e.message}`);
|
|
350
|
-
}
|
|
351
|
-
|
|
352
|
-
let canonicalSpecFile;
|
|
353
|
-
try {
|
|
354
|
-
canonicalSpecFile = fs.realpathSync(specFile);
|
|
355
|
-
if (!fs.statSync(specFile).isFile()) {
|
|
356
|
-
throw candidateError('spec.json must be a regular file');
|
|
357
|
-
}
|
|
358
|
-
} catch (e) {
|
|
359
|
-
if (e && e.code) throw e;
|
|
360
|
-
throw candidateError(`spec.json canonicalization error: ${e.message}`);
|
|
361
|
-
}
|
|
362
|
-
if (canonicalSpecFile !== canonicalSpecs && !isPathInside(canonicalSpecs, canonicalSpecFile)) {
|
|
363
|
-
throw candidateError('spec file symlink escapes specs root');
|
|
364
|
-
}
|
|
365
|
-
const expectedSpecFile = path.join(canonicalFeature, 'spec.json');
|
|
366
|
-
if (canonicalSpecFile !== expectedSpecFile) {
|
|
367
|
-
throw candidateError(`spec.json does not belong to feature directory ${featureName}`);
|
|
368
|
-
}
|
|
369
|
-
|
|
370
|
-
let spec;
|
|
371
|
-
try {
|
|
372
|
-
spec = JSON.parse(fs.readFileSync(canonicalSpecFile, 'utf8'));
|
|
373
|
-
} catch (e) {
|
|
374
|
-
throw candidateError(e.message);
|
|
375
|
-
}
|
|
376
|
-
if (!isPlainObject(spec)) throw candidateError('spec.json must contain a JSON object');
|
|
377
|
-
if (typeof spec.feature_name !== 'string' || spec.feature_name.trim() !== featureName) {
|
|
378
|
-
throw candidateError(`spec.json.feature_name must equal canonical feature name ${featureName}`);
|
|
379
|
-
}
|
|
380
|
-
|
|
381
|
-
return {
|
|
382
|
-
featureName,
|
|
383
|
-
spec,
|
|
384
|
-
specsDir,
|
|
385
|
-
featureDir: canonicalFeature,
|
|
386
|
-
specFile: canonicalSpecFile,
|
|
387
|
-
canonicalFeature,
|
|
388
|
-
canonicalSpecFile,
|
|
389
|
-
featureLstat,
|
|
390
|
-
specLstat,
|
|
391
|
-
};
|
|
392
|
-
}
|
|
393
|
-
|
|
394
|
-
function scanSpecs(specsDir) {
|
|
395
|
-
let canonicalSpecs;
|
|
396
|
-
let specsLstat = null;
|
|
397
|
-
try {
|
|
398
|
-
specsLstat = fs.lstatSync(specsDir);
|
|
399
|
-
} catch (e) {
|
|
400
|
-
if (isMissingError(e)) return { active: [], candidates: [], invalid: [], canonicalSpecs: path.resolve(specsDir) };
|
|
401
|
-
return { active: [], candidates: [], invalid: [{ featureName: '<specs>', reason: `specs lstat error: ${e.message}`, specFile: specsDir }], canonicalSpecs: path.resolve(specsDir) };
|
|
402
|
-
}
|
|
403
|
-
try {
|
|
404
|
-
canonicalSpecs = fs.realpathSync(specsDir);
|
|
405
|
-
} catch (e) {
|
|
406
|
-
return { active: [], candidates: [], invalid: [{ featureName: '<specs>', reason: `specs root canonicalization error: ${e.message}`, specFile: specsDir }], canonicalSpecs: path.resolve(specsDir) };
|
|
407
|
-
}
|
|
408
|
-
|
|
409
|
-
let entries;
|
|
410
|
-
try {
|
|
411
|
-
entries = fs.readdirSync(specsDir, { withFileTypes: true });
|
|
412
|
-
} catch (e) {
|
|
413
|
-
return { active: [], candidates: [], invalid: [{ featureName: '<specs>', reason: `specs directory read error: ${e.message}`, specFile: specsDir }], canonicalSpecs };
|
|
414
|
-
}
|
|
415
|
-
const active = [];
|
|
416
|
-
const candidates = [];
|
|
417
|
-
const invalid = [];
|
|
418
|
-
const seenCanonicalFeatures = new Set();
|
|
419
|
-
const sorted = entries.sort((a, b) => a.name.localeCompare(b.name));
|
|
420
|
-
for (const entry of sorted) {
|
|
421
|
-
try {
|
|
422
|
-
const candidate = inspectFeature(specsDir, canonicalSpecs, entry.name);
|
|
423
|
-
if (candidate.missing) {
|
|
424
|
-
invalid.push({
|
|
425
|
-
featureName: entry.name,
|
|
426
|
-
reason: 'feature entry disappeared during scan',
|
|
427
|
-
specFile: path.join(specsDir, entry.name, 'spec.json'),
|
|
428
|
-
});
|
|
429
|
-
continue;
|
|
430
|
-
}
|
|
431
|
-
if (candidate.missingSpec) {
|
|
432
|
-
continue;
|
|
433
|
-
}
|
|
434
|
-
if (seenCanonicalFeatures.has(candidate.canonicalFeature)) continue;
|
|
435
|
-
seenCanonicalFeatures.add(candidate.canonicalFeature);
|
|
436
|
-
candidates.push(candidate);
|
|
437
|
-
if (candidate.spec.status === 'in_progress' || candidate.spec.status === 'in-progress') {
|
|
438
|
-
active.push(candidate);
|
|
439
|
-
}
|
|
440
|
-
} catch (e) {
|
|
441
|
-
invalid.push({
|
|
442
|
-
featureName: entry.name,
|
|
443
|
-
reason: e.message,
|
|
444
|
-
specFile: path.join(specsDir, entry.name, 'spec.json'),
|
|
445
|
-
});
|
|
446
|
-
}
|
|
447
|
-
}
|
|
448
|
-
return { active, candidates, invalid, canonicalSpecs };
|
|
449
|
-
}
|
|
450
|
-
|
|
451
|
-
function findAllSpecCandidates(projectRoot, runtime) {
|
|
452
|
-
let specsDir;
|
|
453
|
-
try {
|
|
454
|
-
specsDir = specsDirectory(projectRoot, runtime);
|
|
455
|
-
} catch (error) {
|
|
456
|
-
const wrapped = new Error(`Invalid spec candidates: <specs>: ${error.message}`);
|
|
457
|
-
wrapped.code = 'INVALID_SPECS';
|
|
458
|
-
wrapped.invalid = [{ featureName: '<specs>', reason: error.message, specFile: path.resolve(projectRoot, 'specs') }];
|
|
459
|
-
throw wrapped;
|
|
460
|
-
}
|
|
461
|
-
const scanned = scanSpecs(specsDir);
|
|
462
|
-
if (scanned.invalid.length > 0) {
|
|
463
|
-
const error = new Error(`Invalid spec candidates: ${scanned.invalid.map((item) => `${item.featureName}: ${item.reason}`).join('; ')}`);
|
|
464
|
-
error.code = 'INVALID_SPECS';
|
|
465
|
-
error.invalid = scanned.invalid;
|
|
466
|
-
throw error;
|
|
467
|
-
}
|
|
468
|
-
return scanned.candidates.sort((left, right) => left.featureName.localeCompare(right.featureName));
|
|
469
|
-
}
|
|
470
|
-
|
|
471
|
-
function findAllActiveSpecs(projectRoot, runtime) {
|
|
472
|
-
let specsDir;
|
|
473
|
-
try {
|
|
474
|
-
specsDir = specsDirectory(projectRoot, runtime);
|
|
475
|
-
} catch (error) {
|
|
476
|
-
const wrapped = new Error(`Invalid spec candidates: <specs>: ${error.message}`);
|
|
477
|
-
wrapped.code = 'INVALID_SPECS';
|
|
478
|
-
wrapped.invalid = [{ featureName: '<specs>', reason: error.message, specFile: path.resolve(projectRoot, 'specs') }];
|
|
479
|
-
throw wrapped;
|
|
480
|
-
}
|
|
481
|
-
const { active, invalid } = scanSpecs(specsDir);
|
|
482
|
-
if (invalid.length > 0) {
|
|
483
|
-
const error = new Error(`Invalid spec candidates: ${invalid.map((item) => `${item.featureName}: ${item.reason}`).join('; ')}`);
|
|
484
|
-
error.code = 'INVALID_SPECS';
|
|
485
|
-
error.invalid = invalid;
|
|
486
|
-
throw error;
|
|
487
|
-
}
|
|
488
|
-
return active;
|
|
489
|
-
}
|
|
490
|
-
|
|
491
|
-
function isPathInsideLegacy(parent, child) {
|
|
492
|
-
const rel = path.relative(parent, child);
|
|
493
|
-
return rel !== '' && !rel.startsWith('..' + path.sep) && rel !== '..' && !path.isAbsolute(rel);
|
|
494
|
-
}
|
|
495
|
-
|
|
496
284
|
function explicitTargetValue(value) {
|
|
497
285
|
return typeof value === 'string' ? value.trim() : value;
|
|
498
286
|
}
|
|
@@ -535,7 +323,39 @@ function readActiveFeatureTarget({ projectRoot, runtime } = {}) {
|
|
|
535
323
|
const value = parsed.featureName;
|
|
536
324
|
if (typeof value !== 'string' || /[\r\n]/.test(value)) return null;
|
|
537
325
|
const trimmed = value.trim();
|
|
538
|
-
|
|
326
|
+
if (!trimmed) return null;
|
|
327
|
+
// A recorded target naming a legacy spec.json packet is ignored rather than turned into
|
|
328
|
+
// a Stop block: CafeKit no longer reads those packets. A missing directory still reaches
|
|
329
|
+
// the resolver and fails as explicit_not_found; a name that is not a plain directory
|
|
330
|
+
// segment is left for the resolver to reject before anything outside specs is touched.
|
|
331
|
+
if (SAFE_PACKET_NAME.test(trimmed) && isLegacyPacketDir(path.join(specsDir, trimmed))) return null;
|
|
332
|
+
return { explicitFeature: trimmed };
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
// Names printed back to the model are limited to plain directory segments.
|
|
336
|
+
const SAFE_PACKET_NAME = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
|
|
337
|
+
|
|
338
|
+
// A legacy packet is a direct specs/<x>/ directory holding spec.json and no plan.md.
|
|
339
|
+
// A directory with both files is read as a process-first packet.
|
|
340
|
+
function isLegacyPacketDir(featureDir) {
|
|
341
|
+
const info = lstatOptional(featureDir);
|
|
342
|
+
if (!info.exists || info.isSymlink || !info.stat.isDirectory()) return false;
|
|
343
|
+
return fs.existsSync(path.join(featureDir, 'spec.json'))
|
|
344
|
+
&& !fs.existsSync(path.join(featureDir, 'plan.md'));
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
function findLegacyPackets(projectRoot, runtime) {
|
|
348
|
+
let specsDir;
|
|
349
|
+
try {
|
|
350
|
+
specsDir = specsDirectory(projectRoot, runtime || {});
|
|
351
|
+
return fs.readdirSync(specsDir, { withFileTypes: true })
|
|
352
|
+
.filter((entry) => entry.isDirectory() && SAFE_PACKET_NAME.test(entry.name)
|
|
353
|
+
&& isLegacyPacketDir(path.join(specsDir, entry.name)))
|
|
354
|
+
.map((entry) => entry.name)
|
|
355
|
+
.sort();
|
|
356
|
+
} catch {
|
|
357
|
+
return [];
|
|
358
|
+
}
|
|
539
359
|
}
|
|
540
360
|
|
|
541
361
|
/**
|
|
@@ -583,212 +403,6 @@ function lstatOptional(p) {
|
|
|
583
403
|
}
|
|
584
404
|
}
|
|
585
405
|
|
|
586
|
-
function resolveActiveSpec({ projectRoot, runtime, explicitFeature, explicitPath, target } = {}) {
|
|
587
|
-
if (!projectRoot) throw new TypeError('projectRoot required');
|
|
588
|
-
const rt = runtime || {};
|
|
589
|
-
const normalizedTarget = extractExplicitTarget(target);
|
|
590
|
-
if (explicitFeature === undefined && normalizedTarget
|
|
591
|
-
&& Object.prototype.hasOwnProperty.call(normalizedTarget, 'explicitFeature')) {
|
|
592
|
-
explicitFeature = normalizedTarget.explicitFeature;
|
|
593
|
-
}
|
|
594
|
-
if (explicitPath === undefined && normalizedTarget
|
|
595
|
-
&& Object.prototype.hasOwnProperty.call(normalizedTarget, 'explicitPath')) {
|
|
596
|
-
explicitPath = normalizedTarget.explicitPath;
|
|
597
|
-
}
|
|
598
|
-
const hasExplicitFeature = explicitFeature !== undefined && explicitFeature !== null;
|
|
599
|
-
const hasExplicitPath = explicitPath !== undefined && explicitPath !== null;
|
|
600
|
-
const hasExplicitTarget = hasExplicitFeature || hasExplicitPath;
|
|
601
|
-
let specsDir;
|
|
602
|
-
try {
|
|
603
|
-
specsDir = specsDirectory(projectRoot, rt);
|
|
604
|
-
} catch (error) {
|
|
605
|
-
return {
|
|
606
|
-
error: hasExplicitTarget ? 'explicit_malformed' : 'invalid_specs',
|
|
607
|
-
candidates: ['<specs>'],
|
|
608
|
-
explicitFeature,
|
|
609
|
-
explicitPath,
|
|
610
|
-
reason: error.message,
|
|
611
|
-
};
|
|
612
|
-
}
|
|
613
|
-
let canonicalSpecs;
|
|
614
|
-
const specsLstat = lstatOptional(specsDir);
|
|
615
|
-
if (specsLstat.error) {
|
|
616
|
-
if (hasExplicitTarget) {
|
|
617
|
-
return { error: 'explicit_malformed', explicitFeature, explicitPath, reason: `specs lstat error: ${specsLstat.error.message}` };
|
|
618
|
-
}
|
|
619
|
-
return { error: 'invalid_specs', candidates: ['<specs>'], invalid: [{ featureName: '<specs>', reason: `specs lstat error: ${specsLstat.error.message}`, specFile: specsDir }], reason: `Invalid spec JSON: <specs>: specs lstat error: ${specsLstat.error.message}` };
|
|
620
|
-
}
|
|
621
|
-
if (!specsLstat.exists) {
|
|
622
|
-
canonicalSpecs = path.resolve(specsDir);
|
|
623
|
-
} else {
|
|
624
|
-
try {
|
|
625
|
-
canonicalSpecs = fs.realpathSync(specsDir);
|
|
626
|
-
} catch (e) {
|
|
627
|
-
if (hasExplicitTarget) {
|
|
628
|
-
return { error: 'explicit_malformed', explicitFeature, explicitPath, reason: `specs root canonicalization error: ${e.message}` };
|
|
629
|
-
}
|
|
630
|
-
return { error: 'invalid_specs', candidates: ['<specs>'], invalid: [{ featureName: '<specs>', reason: `specs root canonicalization error: ${e.message}`, specFile: specsDir }], reason: `Invalid spec JSON: <specs>: specs root canonicalization error: ${e.message}` };
|
|
631
|
-
}
|
|
632
|
-
}
|
|
633
|
-
|
|
634
|
-
if (hasExplicitFeature) {
|
|
635
|
-
if (typeof explicitFeature !== 'string' || explicitFeature.trim() === '' || explicitFeature.includes('/') || explicitFeature.includes('\\') || explicitFeature.includes('..')) {
|
|
636
|
-
return { error: 'explicit_malformed', explicitFeature, reason: `malformed feature name: ${explicitFeature}` };
|
|
637
|
-
}
|
|
638
|
-
const featureDir = path.join(specsDir, explicitFeature);
|
|
639
|
-
const resolvedFeature = path.resolve(featureDir);
|
|
640
|
-
if (!isPathInsideLegacy(path.resolve(specsDir), resolvedFeature) && resolvedFeature !== path.resolve(specsDir)) {
|
|
641
|
-
return { error: 'explicit_malformed', explicitFeature, reason: 'feature path escapes specs root' };
|
|
642
|
-
}
|
|
643
|
-
if (path.relative(path.resolve(specsDir), resolvedFeature) !== explicitFeature) {
|
|
644
|
-
return { error: 'explicit_malformed', explicitFeature, reason: 'feature name does not canonicalize inside specs root' };
|
|
645
|
-
}
|
|
646
|
-
try {
|
|
647
|
-
const candidate = inspectFeature(specsDir, canonicalSpecs, explicitFeature);
|
|
648
|
-
if (candidate.missing || candidate.missingSpec) {
|
|
649
|
-
return { error: 'explicit_not_found', explicitFeature, reason: `spec not found for feature ${explicitFeature}` };
|
|
650
|
-
}
|
|
651
|
-
if (candidate.featureName !== explicitFeature) {
|
|
652
|
-
return {
|
|
653
|
-
error: 'explicit_malformed',
|
|
654
|
-
explicitFeature,
|
|
655
|
-
reason: `feature directory resolves to canonical feature ${candidate.featureName}`,
|
|
656
|
-
};
|
|
657
|
-
}
|
|
658
|
-
return candidate;
|
|
659
|
-
} catch (e) {
|
|
660
|
-
return { error: 'explicit_malformed', explicitFeature, reason: e.message };
|
|
661
|
-
}
|
|
662
|
-
}
|
|
663
|
-
|
|
664
|
-
if (hasExplicitPath) {
|
|
665
|
-
if (typeof explicitPath !== 'string' || explicitPath.trim() === '') {
|
|
666
|
-
return { error: 'explicit_malformed', explicitPath, reason: 'empty explicit path' };
|
|
667
|
-
}
|
|
668
|
-
let specFile = explicitPath;
|
|
669
|
-
if (!path.isAbsolute(specFile)) {
|
|
670
|
-
specFile = path.resolve(projectRoot, specFile);
|
|
671
|
-
} else {
|
|
672
|
-
specFile = path.resolve(specFile);
|
|
673
|
-
}
|
|
674
|
-
try {
|
|
675
|
-
const st = fs.lstatSync(specFile);
|
|
676
|
-
if (st.isDirectory()) {
|
|
677
|
-
specFile = path.join(specFile, 'spec.json');
|
|
678
|
-
} else if (st.isSymbolicLink()) {
|
|
679
|
-
try {
|
|
680
|
-
if (fs.statSync(specFile).isDirectory()) specFile = path.join(specFile, 'spec.json');
|
|
681
|
-
} catch (e) {
|
|
682
|
-
if (!isMissingError(e)) throw e;
|
|
683
|
-
}
|
|
684
|
-
}
|
|
685
|
-
} catch (e) {
|
|
686
|
-
if (!isMissingError(e)) {
|
|
687
|
-
return { error: 'explicit_malformed', explicitPath, reason: `explicit path stat error: ${e.message}` };
|
|
688
|
-
}
|
|
689
|
-
// If lstat is missing, the candidate is handled as an explicit not-found below.
|
|
690
|
-
}
|
|
691
|
-
const featureDir = path.dirname(specFile);
|
|
692
|
-
if (!isPathInsideLegacy(path.resolve(specsDir), path.resolve(featureDir)) && path.resolve(featureDir) !== path.resolve(specsDir)) {
|
|
693
|
-
return { error: 'explicit_malformed', explicitPath, reason: 'explicit path escapes specs root' };
|
|
694
|
-
}
|
|
695
|
-
const rel = path.relative(path.resolve(specsDir), path.resolve(specFile));
|
|
696
|
-
const segs = rel.split(path.sep);
|
|
697
|
-
if (segs.length !== 2 || segs[1] !== 'spec.json') {
|
|
698
|
-
return { error: 'explicit_malformed', explicitPath, reason: 'explicit path must be <specs>/<feature>/spec.json or <specs>/<feature>' };
|
|
699
|
-
}
|
|
700
|
-
try {
|
|
701
|
-
const candidate = inspectFeature(specsDir, canonicalSpecs, segs[0]);
|
|
702
|
-
if (candidate.missing || candidate.missingSpec) {
|
|
703
|
-
return { error: 'explicit_not_found', explicitPath, reason: `spec not found at ${explicitPath}` };
|
|
704
|
-
}
|
|
705
|
-
if (candidate.featureName !== segs[0]) {
|
|
706
|
-
return {
|
|
707
|
-
error: 'explicit_malformed',
|
|
708
|
-
explicitPath,
|
|
709
|
-
reason: `explicit path resolves to canonical feature ${candidate.featureName}, not ${segs[0]}`,
|
|
710
|
-
};
|
|
711
|
-
}
|
|
712
|
-
return candidate;
|
|
713
|
-
} catch (e) {
|
|
714
|
-
return { error: 'explicit_malformed', explicitPath, reason: e.message };
|
|
715
|
-
}
|
|
716
|
-
}
|
|
717
|
-
|
|
718
|
-
const { active, invalid } = scanSpecs(specsDir);
|
|
719
|
-
if (invalid.length > 0) {
|
|
720
|
-
return {
|
|
721
|
-
error: 'invalid_specs',
|
|
722
|
-
candidates: invalid.map((i) => i.featureName),
|
|
723
|
-
invalid,
|
|
724
|
-
reason: `Invalid spec JSON: ${invalid.map((i) => `${i.featureName}: ${i.reason}`).join('; ')}`,
|
|
725
|
-
};
|
|
726
|
-
}
|
|
727
|
-
active.sort((left, right) => left.featureName.localeCompare(right.featureName));
|
|
728
|
-
if (active.length === 0) return null;
|
|
729
|
-
if (active.length === 1) return active[0];
|
|
730
|
-
return {
|
|
731
|
-
error: 'multiple_active',
|
|
732
|
-
candidates: active.map((a) => a.featureName),
|
|
733
|
-
active,
|
|
734
|
-
reason: `Multiple active specs found: ${active.map((a) => a.featureName).join(', ')}. Provide explicit feature.`,
|
|
735
|
-
};
|
|
736
|
-
}
|
|
737
|
-
|
|
738
|
-
/**
|
|
739
|
-
* Resolve one persisted feature identity. Explicit host targets are inspected
|
|
740
|
-
* directly and never scan siblings; only the no-target path performs a global
|
|
741
|
-
* persisted-candidate scan and therefore owns ambiguity/invalid-sibling errors.
|
|
742
|
-
*/
|
|
743
|
-
function resolvePersistedSpec({ projectRoot, runtime, explicitFeature, explicitPath, target } = {}) {
|
|
744
|
-
const normalized = extractExplicitTarget(
|
|
745
|
-
target,
|
|
746
|
-
explicitFeature !== undefined ? { explicitFeature } : null,
|
|
747
|
-
explicitPath !== undefined ? { explicitPath } : null,
|
|
748
|
-
);
|
|
749
|
-
if (normalized) {
|
|
750
|
-
const value = Object.prototype.hasOwnProperty.call(normalized, 'explicitFeature')
|
|
751
|
-
? normalized.explicitFeature
|
|
752
|
-
: normalized.explicitPath;
|
|
753
|
-
if (value === null || value === undefined) {
|
|
754
|
-
return { error: 'explicit_malformed', ...normalized, reason: 'explicit target must be a non-empty string' };
|
|
755
|
-
}
|
|
756
|
-
return resolveActiveSpec({ projectRoot, runtime, ...normalized });
|
|
757
|
-
}
|
|
758
|
-
// Only now, with the caller having named nothing: a recorded target must never
|
|
759
|
-
// redirect a hook that already knows which feature its turn is about.
|
|
760
|
-
const recorded = readActiveFeatureTarget({ projectRoot, runtime });
|
|
761
|
-
if (recorded) return resolveActiveSpec({ projectRoot, runtime, ...recorded });
|
|
762
|
-
try {
|
|
763
|
-
const candidates = findAllSpecCandidates(projectRoot, runtime);
|
|
764
|
-
if (candidates.length === 0) return null;
|
|
765
|
-
// One packet resolves on its own terms. Closeout approval is claimed against a
|
|
766
|
-
// finished spec, so status must never decide identity here.
|
|
767
|
-
if (candidates.length === 1) return candidates[0];
|
|
768
|
-
// This path serves closeout approval, so only a packet actually claiming closeout is
|
|
769
|
-
// a real rival. Counting finished history as ambiguity is what made the gate
|
|
770
|
-
// unsatisfiable in a repository that had simply accumulated features.
|
|
771
|
-
const { isDurableCloseout } = require('./spec-final-state.cjs');
|
|
772
|
-
const claiming = candidates.filter((candidate) => isDurableCloseout(candidate.spec));
|
|
773
|
-
if (claiming.length === 1) return claiming[0];
|
|
774
|
-
if (claiming.length === 0) return null;
|
|
775
|
-
return {
|
|
776
|
-
error: 'multiple_persisted',
|
|
777
|
-
candidates: claiming.map((candidate) => candidate.featureName),
|
|
778
|
-
reason: `Multiple persisted specs found: ${claiming.map((candidate) => candidate.featureName).join(', ')}. Provide explicit feature.`,
|
|
779
|
-
};
|
|
780
|
-
} catch (error) {
|
|
781
|
-
return {
|
|
782
|
-
error: 'invalid_specs',
|
|
783
|
-
candidates: Array.isArray(error.invalid)
|
|
784
|
-
? error.invalid.map((entry) => entry.featureName)
|
|
785
|
-
: ['<specs>'],
|
|
786
|
-
invalid: error.invalid || [],
|
|
787
|
-
reason: error.message,
|
|
788
|
-
};
|
|
789
|
-
}
|
|
790
|
-
}
|
|
791
|
-
|
|
792
406
|
function scanWorkflowFeatures(specsDir) {
|
|
793
407
|
let canonicalSpecs;
|
|
794
408
|
try {
|
|
@@ -807,7 +421,7 @@ function scanWorkflowFeatures(specsDir) {
|
|
|
807
421
|
for (const entry of entries) {
|
|
808
422
|
try {
|
|
809
423
|
const candidate = inspectWorkflowFeature(canonicalSpecs, canonicalSpecs, entry.name);
|
|
810
|
-
if (candidate.missing || candidate.missingWorkflow
|
|
424
|
+
if (candidate.missing || candidate.missingWorkflow) continue;
|
|
811
425
|
candidates.push(candidate);
|
|
812
426
|
if (!candidate.allTasksDone) active.push(candidate);
|
|
813
427
|
} catch (error) {
|
|
@@ -859,8 +473,8 @@ function explicitWorkflowFeatureName({ projectRoot, specsDir, explicitFeature, e
|
|
|
859
473
|
}
|
|
860
474
|
|
|
861
475
|
/**
|
|
862
|
-
* Resolve the
|
|
863
|
-
*
|
|
476
|
+
* Resolve the process-first packet (plan.md plus flat task files) visible to prompt
|
|
477
|
+
* and Stop hooks.
|
|
864
478
|
*/
|
|
865
479
|
function resolveWorkflowCandidate({ projectRoot, runtime, explicitFeature, explicitPath, target, includeCompleted = false } = {}) {
|
|
866
480
|
if (!projectRoot) throw new TypeError('projectRoot required');
|
|
@@ -883,20 +497,14 @@ function resolveWorkflowCandidate({ projectRoot, runtime, explicitFeature, expli
|
|
|
883
497
|
}
|
|
884
498
|
|
|
885
499
|
if (hasExplicit) {
|
|
886
|
-
const legacyDirect = resolveActiveSpec({ projectRoot, runtime, explicitFeature, explicitPath });
|
|
887
|
-
if (legacyDirect && !legacyDirect.error) return normalizeLegacyWorkflowCandidate(legacyDirect);
|
|
888
500
|
const identity = explicitWorkflowFeatureName({ projectRoot, specsDir, explicitFeature, explicitPath });
|
|
889
501
|
if (identity.error) return identity;
|
|
890
502
|
|
|
891
|
-
const legacy = resolveActiveSpec({ projectRoot, runtime, explicitFeature: identity.featureName });
|
|
892
|
-
if (legacy && !legacy.error) return normalizeLegacyWorkflowCandidate(legacy);
|
|
893
|
-
if (legacy && legacy.error !== 'explicit_not_found') return legacy;
|
|
894
|
-
|
|
895
503
|
let canonicalSpecs;
|
|
896
504
|
try {
|
|
897
505
|
canonicalSpecs = fs.realpathSync(specsDir);
|
|
898
506
|
const candidate = inspectWorkflowFeature(specsDir, canonicalSpecs, identity.featureName);
|
|
899
|
-
if (candidate.missing || candidate.missingWorkflow
|
|
507
|
+
if (candidate.missing || candidate.missingWorkflow) {
|
|
900
508
|
return { error: 'explicit_not_found', explicitFeature: identity.featureName, explicitPath, reason: `workflow not found for feature ${identity.featureName}` };
|
|
901
509
|
}
|
|
902
510
|
return candidate;
|
|
@@ -905,20 +513,6 @@ function resolveWorkflowCandidate({ projectRoot, runtime, explicitFeature, expli
|
|
|
905
513
|
}
|
|
906
514
|
}
|
|
907
515
|
|
|
908
|
-
let legacyActive;
|
|
909
|
-
try {
|
|
910
|
-
const legacyCandidates = includeCompleted
|
|
911
|
-
? findAllSpecCandidates(projectRoot, runtime || {})
|
|
912
|
-
: findAllActiveSpecs(projectRoot, runtime || {});
|
|
913
|
-
legacyActive = legacyCandidates.map(normalizeLegacyWorkflowCandidate);
|
|
914
|
-
} catch (error) {
|
|
915
|
-
return {
|
|
916
|
-
error: 'invalid_specs',
|
|
917
|
-
candidates: Array.isArray(error.invalid) ? error.invalid.map((entry) => entry.featureName) : ['<specs>'],
|
|
918
|
-
invalid: error.invalid || [],
|
|
919
|
-
reason: error.message,
|
|
920
|
-
};
|
|
921
|
-
}
|
|
922
516
|
const workflow = scanWorkflowFeatures(specsDir);
|
|
923
517
|
if (workflow.invalid.length > 0) {
|
|
924
518
|
return {
|
|
@@ -929,7 +523,7 @@ function resolveWorkflowCandidate({ projectRoot, runtime, explicitFeature, expli
|
|
|
929
523
|
};
|
|
930
524
|
}
|
|
931
525
|
const workflowCandidates = includeCompleted ? workflow.candidates : workflow.active;
|
|
932
|
-
const active = [...
|
|
526
|
+
const active = [...workflowCandidates]
|
|
933
527
|
.sort((left, right) => left.featureName.localeCompare(right.featureName));
|
|
934
528
|
if (active.length === 0) return null;
|
|
935
529
|
if (active.length === 1) return active[0];
|
|
@@ -942,8 +536,7 @@ function resolveWorkflowCandidate({ projectRoot, runtime, explicitFeature, expli
|
|
|
942
536
|
}
|
|
943
537
|
|
|
944
538
|
/**
|
|
945
|
-
* Narrow a Stop-gate ambiguity
|
|
946
|
-
* A single unfinished process-v3 packet is the current workflow; completed
|
|
539
|
+
* Narrow a Stop-gate ambiguity. A single unfinished process-v3 packet is the current workflow; completed
|
|
947
540
|
* packets are historical. If every process-v3 packet is complete, return the
|
|
948
541
|
* set so the gate can revalidate all receipts instead of blocking on identity.
|
|
949
542
|
*/
|
|
@@ -954,9 +547,7 @@ function refineWorkflowGateResolution(resolved) {
|
|
|
954
547
|
const candidates = resolved.active;
|
|
955
548
|
if (candidates.length === 0) return resolved;
|
|
956
549
|
|
|
957
|
-
// Which packet still has work decides this
|
|
958
|
-
// legacy packets was permanently ambiguous only because the check below used to sit
|
|
959
|
-
// here and reject the whole set on sight.
|
|
550
|
+
// Which packet still has work decides this.
|
|
960
551
|
const unfinished = candidates.filter((candidate) => !candidate.allTasksDone);
|
|
961
552
|
if (unfinished.length === 1) return unfinished[0];
|
|
962
553
|
if (unfinished.length > 1) {
|
|
@@ -968,10 +559,6 @@ function refineWorkflowGateResolution(resolved) {
|
|
|
968
559
|
reason: `Multiple active workflows found: ${unfinished.map((candidate) => candidate.featureName).join(', ')}. Provide explicit feature.`,
|
|
969
560
|
};
|
|
970
561
|
}
|
|
971
|
-
// The bulk-audit branch exits before the semantic-digest, FLASH_UNVERIFIED,
|
|
972
|
-
// feature-receipt, and completion-policy layers that the single-candidate path runs, so
|
|
973
|
-
// only process-first packets may reach it. Anything else keeps the existing ambiguity.
|
|
974
|
-
if (candidates.some((candidate) => candidate.layoutKind !== 'process-v3')) return resolved;
|
|
975
562
|
return {
|
|
976
563
|
layoutKind: 'process-v3-completed-set',
|
|
977
564
|
candidates,
|
|
@@ -982,13 +569,10 @@ function refineWorkflowGateResolution(resolved) {
|
|
|
982
569
|
|
|
983
570
|
module.exports = {
|
|
984
571
|
annotatedMarkdownLines,
|
|
572
|
+
findLegacyPackets,
|
|
985
573
|
specsDirectory,
|
|
986
|
-
findAllActiveSpecs,
|
|
987
|
-
findAllSpecCandidates,
|
|
988
574
|
extractExplicitTarget,
|
|
989
|
-
resolveActiveSpec,
|
|
990
575
|
readActiveFeatureTarget,
|
|
991
|
-
resolvePersistedSpec,
|
|
992
576
|
resolveWorkflowCandidate,
|
|
993
577
|
refineWorkflowGateResolution,
|
|
994
578
|
};
|