@tea-agent/loop-agent 0.21.0 → 0.23.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/AGENTS.md +42 -108
- package/CHANGELOG.md +114 -0
- package/README.md +7 -4
- package/bin/agent-worker.js +0 -0
- package/dist/adapters/loop-agent.js +52 -0
- package/dist/application/context-usage/skill-resolution-stats.js +263 -0
- package/dist/application/dag/generate-task-dag.js +17 -3
- package/dist/cli/command-definitions.js +8 -7
- package/dist/cli/program.js +17 -15
- package/dist/commands/doctor.js +269 -18
- package/dist/commands/init.js +198 -86
- package/dist/commands/stats.js +40 -11
- package/dist/executors/dag-pi-executor.js +2 -0
- package/dist/executors/shell-executor.js +162 -19
- package/dist/shared/openspec-spec.js +49 -0
- package/dist/shared/operator/capabilities.js +11 -1
- package/dist/worker/console/app-data.js +4 -0
- package/dist/worker/console/chat/instruction-skills.js +217 -0
- package/dist/worker/console/chat/model-resolver.js +106 -0
- package/dist/worker/console/chat/pi-runtime.js +605 -0
- package/dist/worker/console/chat/resource-loader.js +66 -0
- package/dist/worker/console/chat/routes.js +357 -0
- package/dist/worker/console/chat/session-store.js +238 -0
- package/dist/worker/console/chat/tool-adapter.js +238 -0
- package/dist/worker/console/chat/tools.js +171 -0
- package/dist/worker/console/server.js +55 -0
- package/dist/worker/console/static/assets/index-DVl7Jxt5.js +25 -0
- package/dist/worker/console/static/assets/index-lVcIr9Ju.css +1 -0
- package/dist/worker/console/static/index.html +2 -2
- package/dist/worker/feature/profile-schema.js +1 -1
- package/dist/worker/observability/read-model.js +21 -1
- package/dist/worker/observe/spec-evidence.js +12 -15
- package/dist/worker/observe/static/dag-helpers.js +22 -0
- package/dist/worker/observe/static/views/dag.js +5 -0
- package/dist/workflows/dag/backend-test-markdown-workflow.js +37 -0
- package/dist/workflows/dag/frontend-implementation-contract.js +141 -32
- package/dist/workflows/dag/frontend-lint-baseline.js +471 -0
- package/dist/workflows/dag/frontend-prewrite-gate.js +79 -16
- package/dist/workflows/dag/frontend-project-capability.js +11 -8
- package/dist/workflows/dag/frontend-repair.js +6 -4
- package/dist/workflows/dag/frontend-review-context.js +67 -0
- package/dist/workflows/dag/frontend-test-case-quality.js +105 -0
- package/dist/workflows/dag/frontend-test-result-contract.js +71 -66
- package/dist/workflows/dag/frontend-verification-trace.js +31 -1
- package/dist/workflows/dag/frontend-worktree-diff.js +81 -6
- package/dist/workflows/dag/init-hybrid.js +412 -84
- package/dist/workflows/dag/node-execution.js +38 -1
- package/dist/workflows/dag/output-protocol.js +89 -0
- package/dist/workflows/dag/prompt.js +35 -1
- package/dist/workflows/dag/recovery-recommendation.js +45 -0
- package/dist/workflows/dag/report.js +28 -1
- package/dist/workflows/dag/rerun-task.js +1 -1
- package/dist/workflows/dag/scheduler.js +9 -0
- package/dist/workflows/dag/types.js +74 -1
- package/dist/workflows/dag/validate.js +55 -0
- package/docs/README.md +73 -156
- package/docs/architecture/README.md +3 -2
- package/docs/architecture/dag-execution.md +2 -2
- package/docs/architecture/evolution.md +14 -12
- package/docs/architecture/system-overview.md +1 -1
- package/docs/architecture/worker-and-feature.md +3 -3
- package/docs/governance/README.md +15 -0
- package/docs/{harness-methodology-debugging.md → governance/harness-methodology-debugging.md} +27 -3
- package/docs/init-surface.manifest.json +22 -4
- package/docs/operations/README.md +12 -0
- package/docs/{local-development-environment.md → operations/local-development-environment.md} +1 -1
- package/docs/skills/vetted-skill-registry.md +23 -3
- package/docs/templates/README.md +55 -0
- package/docs/templates/agent-dag.schema.json +15 -5
- package/docs/templates/backend-test-dag.json +1 -1
- package/docs/templates/evaluation/agents-map-slim-v1.candidate.json +9 -0
- package/docs/templates/evaluation/agents-map-slim-v1.md +87 -0
- package/docs/templates/evaluation/agents-map-verbose-v0.candidate.json +9 -0
- package/docs/templates/evaluation/agents-map-verbose-v0.md +153 -0
- package/docs/templates/frontend-implementation-contract.schema.json +4 -3
- package/docs/templates/frontend-test-case-checklist.md +6 -2
- package/docs/templates/frontend-test-dag.json +2 -2
- package/docs/templates/hybrid-dag.json +1 -1
- package/docs/templates/progress-log.md +9 -2
- package/harness.json +5 -5
- package/package.json +5 -5
- package/scripts/kb-bootstrap-init-skeleton.sh +2 -2
- package/skills/agent-worker/SKILL.md +1 -1
- package/skills/frontend-design-review/SKILL.md +12 -10
- package/skills/frontend-design-review/references/review-checklist.md +4 -4
- package/skills/frontend-implementation/SKILL.md +2 -2
- package/skills/frontend-implementation/references/code-standards.md +4 -3
- package/skills/frontend-implementation/references/design-spec.md +19 -14
- package/skills/frontend-implementation/references/node-contracts.md +2 -2
- package/skills/frontend-review/SKILL.md +15 -28
- package/skills/frontend-review/references/review-findings.md +16 -18
- package/skills/frontend-verification/SKILL.md +16 -13
- package/skills/frontend-verification/references/verification-checklist.md +18 -30
- package/skills/grill-with-docs/SKILL.md +44 -52
- package/skills/grill-with-docs/adr-format.md +37 -26
- package/skills/grill-with-docs/context-format.md +18 -26
- package/skills/loop-agent/SKILL.md +28 -112
- package/skills/loop-agent/references/command-reference.md +9 -3
- package/skills/loop-agent/references/harness-policy.md +3 -3
- package/skills/loop-agent/references/hybrid-dag.md +1 -1
- package/skills/loop-agent/references/orchestrator-and-interventions.md +1 -1
- package/skills/loop-agent/references/task-workflow.md +2 -0
- package/skills/systematic-debugging/SKILL.md +20 -4
- package/skills/test-driven-development/SKILL.md +10 -3
- package/dist/worker/console/static/assets/index-CUDke82y.js +0 -18
- package/dist/worker/console/static/assets/index-wSEksVSO.css +0 -1
- /package/docs/{harness-methodology-tdd.md → governance/harness-methodology-tdd.md} +0 -0
- /package/docs/{harness-methodology-verification.md → governance/harness-methodology-verification.md} +0 -0
|
@@ -14,6 +14,7 @@ import { formatFrontendWorktreeDiffStdout, runFrontendWorktreeDiffGate, } from "
|
|
|
14
14
|
import { formatFrontendFailureAssessStdout, formatFrontendRepairContractStdout, runFrontendFailureAssessGate, runFrontendRepairContractGate, } from "../workflows/dag/frontend-repair.js";
|
|
15
15
|
import { formatFrontendPrewriteGateStdout, runFrontendPrewriteGate, } from "../workflows/dag/frontend-prewrite-gate.js";
|
|
16
16
|
import { formatFrontendReviewContextStdout, runFrontendReviewContextGate, } from "../workflows/dag/frontend-review-context.js";
|
|
17
|
+
import { materializeFrontendLintAssessment, materializeFrontendLintBaseline, } from "../workflows/dag/frontend-lint-baseline.js";
|
|
17
18
|
import { formatTraceabilityGateStdout, materializeBackendTestCaseManifest, runBackendTestTraceabilityGate, } from "../workflows/dag/backend-test-case-manifest.js";
|
|
18
19
|
import { materializeBackendTestExecutionContract } from "../workflows/dag/backend-test-execution-contract.js";
|
|
19
20
|
import { materializeBackendTestResultFromRunDir, parseJunitXml } from "../workflows/dag/backend-test-result-contract.js";
|
|
@@ -381,7 +382,9 @@ async function executeBackendTestPipeline(input, meta) {
|
|
|
381
382
|
const pytestTargets = mappedScripts.map(shellQuote).join(" ");
|
|
382
383
|
const pytestCommand = [
|
|
383
384
|
'mkdir -p "${HARNESS_DAG_RUN_DIR}/reports"',
|
|
384
|
-
|
|
385
|
+
'PYTHON_BIN="$(command -v python || command -v python3 || true)"',
|
|
386
|
+
'if [ -z "${PYTHON_BIN}" ]; then echo "python/python3 is required for backend-test execution" >&2; exit 127; fi',
|
|
387
|
+
`PYTHONUTF8=1 PYTHONIOENCODING=utf-8 PYTHONDONTWRITEBYTECODE=1 "\${PYTHON_BIN}" -m pytest ${pytestTargets} -v -p no:cacheprovider -o junit_logging=all -o junit_log_passing_tests=true --junitxml="\${HARNESS_DAG_RUN_DIR}/reports/backend-test.junit.xml"`,
|
|
385
388
|
"STATUS=$?",
|
|
386
389
|
'printf "%s" "${STATUS}" > "${HARNESS_DAG_RUN_DIR}/reports/backend-test-pytest-exit.txt"',
|
|
387
390
|
'if { [ "${STATUS}" -eq 0 ] || [ "${STATUS}" -eq 1 ]; } && [ -s "${HARNESS_DAG_RUN_DIR}/reports/backend-test.junit.xml" ]; then exit 0; fi',
|
|
@@ -411,9 +414,10 @@ async function executeBackendTestPipeline(input, meta) {
|
|
|
411
414
|
});
|
|
412
415
|
const htmlPath = await writeRunReport(meta.runDir, "backend-test.html", htmlContent);
|
|
413
416
|
const facts = renderBackendTestFacts({ parsed, cases, pytestExitCode, junitRelativePath: "reports/backend-test.junit.xml", htmlRelativePath: "reports/backend-test.html", junitContent, htmlContent, caseValidationSummary, traceabilitySummary });
|
|
417
|
+
const markdownPath = await writeRunReport(meta.runDir, "backend-test.md", facts);
|
|
414
418
|
const factsPath = await writeRunReport(meta.runDir, "backend-test-facts.md", facts);
|
|
415
419
|
const sanitizedOutputs = results.map((result) => redactBackendTestOutput(result.stdout));
|
|
416
|
-
outputs.push(...sanitizedOutputs, `html=${htmlPath}`, `facts=${factsPath}`, facts);
|
|
420
|
+
outputs.push(...sanitizedOutputs, `html=${htmlPath}`, `markdown=${markdownPath}`, `facts=${factsPath}`, facts);
|
|
417
421
|
}
|
|
418
422
|
else if (pipeline === "contracts") {
|
|
419
423
|
const wrapperPath = path.join(meta.runDir, "analyze-and-discover-backend-test-pi.json");
|
|
@@ -661,13 +665,46 @@ async function executeFrontendVerificationBundle(input, meta) {
|
|
|
661
665
|
catch {
|
|
662
666
|
beforeStatus = undefined;
|
|
663
667
|
}
|
|
668
|
+
const lintResults = [];
|
|
669
|
+
for (const command of bundle.lintCommands ?? []) {
|
|
670
|
+
const commandNumber = results.length + 1;
|
|
671
|
+
const result = await executeShellCommand({
|
|
672
|
+
command,
|
|
673
|
+
cwd,
|
|
674
|
+
timeoutMs: shell.timeoutMs ?? DEFAULT_SHELL_TIMEOUT_MS,
|
|
675
|
+
envAllowlist: shell.envAllowlist,
|
|
676
|
+
dagRunMeta: { runDir: meta.runDir, runId: meta.runId },
|
|
677
|
+
outputArtifacts: {
|
|
678
|
+
stdoutPath: path.join(meta.runDir, input.task.id, "commands", `${commandNumber}.stdout.txt`),
|
|
679
|
+
stderrPath: path.join(meta.runDir, input.task.id, "commands", `${commandNumber}.stderr.txt`),
|
|
680
|
+
},
|
|
681
|
+
});
|
|
682
|
+
lintResults.push(result);
|
|
683
|
+
results.push(result);
|
|
684
|
+
}
|
|
685
|
+
let lintAssessment;
|
|
686
|
+
if ((bundle.lintCommands?.length ?? 0) > 0 &&
|
|
687
|
+
bundle.lintBaselineNodeId &&
|
|
688
|
+
(bundle.writerNodeIds?.length ?? 0) > 0) {
|
|
689
|
+
lintAssessment = await materializeFrontendLintAssessment({
|
|
690
|
+
runDir: meta.runDir,
|
|
691
|
+
workspaceRoot: input.cwd,
|
|
692
|
+
commands: bundle.lintCommands,
|
|
693
|
+
results: lintResults,
|
|
694
|
+
baselineNodeId: bundle.lintBaselineNodeId,
|
|
695
|
+
writerNodeIds: bundle.writerNodeIds,
|
|
696
|
+
});
|
|
697
|
+
}
|
|
698
|
+
const lintBlocked = lintAssessment?.status === "failed" ||
|
|
699
|
+
lintAssessment?.status === "unavailable";
|
|
664
700
|
const groups = [
|
|
665
|
-
{ name: "mock", commands: bundle.mockCommands },
|
|
666
|
-
{ name: "static", commands: bundle.staticCommands },
|
|
667
|
-
{ name: "behavior", commands: bundle.behaviorCommands },
|
|
701
|
+
{ name: "mock", commands: bundle.mockCommands, labels: bundle.mockEvidence?.commandLabels ?? [] },
|
|
702
|
+
{ name: "static", commands: bundle.staticCommands, labels: bundle.staticEvidence.commandLabels },
|
|
703
|
+
{ name: "behavior", commands: bundle.behaviorCommands, labels: bundle.behaviorEvidence.commandLabels },
|
|
668
704
|
];
|
|
669
|
-
|
|
670
|
-
|
|
705
|
+
const successfulLabels = new Map();
|
|
706
|
+
for (const group of lintBlocked ? [] : groups) {
|
|
707
|
+
for (const [index, command] of group.commands.entries()) {
|
|
671
708
|
const commandNumber = results.length + 1;
|
|
672
709
|
const result = await executeShellCommand({
|
|
673
710
|
command,
|
|
@@ -681,6 +718,11 @@ async function executeFrontendVerificationBundle(input, meta) {
|
|
|
681
718
|
},
|
|
682
719
|
});
|
|
683
720
|
results.push(result);
|
|
721
|
+
if (result.ok && group.labels[index]) {
|
|
722
|
+
const labels = successfulLabels.get(group.name) ?? [];
|
|
723
|
+
labels.push(group.labels[index]);
|
|
724
|
+
successfulLabels.set(group.name, labels);
|
|
725
|
+
}
|
|
684
726
|
if (!result.ok)
|
|
685
727
|
break;
|
|
686
728
|
}
|
|
@@ -713,20 +755,36 @@ async function executeFrontendVerificationBundle(input, meta) {
|
|
|
713
755
|
failureCategory: result.failureCategory,
|
|
714
756
|
command: result.command,
|
|
715
757
|
}));
|
|
716
|
-
const firstFailure =
|
|
758
|
+
const firstFailure = lintBlocked
|
|
759
|
+
? lintResults.find((result) => !result.ok)
|
|
760
|
+
: results
|
|
761
|
+
.filter((result) => !lintResults.includes(result))
|
|
762
|
+
.find((result) => !result.ok);
|
|
763
|
+
const lintSyntheticFailure = lintBlocked && !firstFailure
|
|
764
|
+
? {
|
|
765
|
+
failureCategory: "invalid-output",
|
|
766
|
+
stderr: lintAssessment?.blockingReasons.join("; ") ??
|
|
767
|
+
"frontend lint assessment failed",
|
|
768
|
+
}
|
|
769
|
+
: undefined;
|
|
717
770
|
let traceError;
|
|
718
771
|
try {
|
|
719
772
|
await runFrontendVerificationTraceGate({
|
|
720
773
|
runDir: meta.runDir,
|
|
721
774
|
workspaceRoot: input.cwd,
|
|
722
775
|
evidence: {
|
|
776
|
+
mock: {
|
|
777
|
+
nodeId: input.task.id,
|
|
778
|
+
commandLabels: successfulLabels.get("mock") ?? [],
|
|
779
|
+
commandTexts: bundle.mockCommands.slice(0, successfulLabels.get("mock")?.length ?? 0),
|
|
780
|
+
},
|
|
723
781
|
static: {
|
|
724
782
|
nodeId: input.task.id,
|
|
725
|
-
commandLabels:
|
|
783
|
+
commandLabels: successfulLabels.get("static") ?? [],
|
|
726
784
|
},
|
|
727
785
|
behavior: {
|
|
728
786
|
nodeId: input.task.id,
|
|
729
|
-
commandLabels:
|
|
787
|
+
commandLabels: successfulLabels.get("behavior") ?? [],
|
|
730
788
|
},
|
|
731
789
|
},
|
|
732
790
|
});
|
|
@@ -735,19 +793,26 @@ async function executeFrontendVerificationBundle(input, meta) {
|
|
|
735
793
|
traceError = error instanceof Error ? error : new Error(String(error));
|
|
736
794
|
}
|
|
737
795
|
if (bundle.mode === "repair") {
|
|
738
|
-
if (firstFailure || traceError) {
|
|
796
|
+
if (firstFailure || lintSyntheticFailure || traceError) {
|
|
739
797
|
return {
|
|
740
798
|
ok: false,
|
|
741
799
|
stdout: summarizeCommandResults(results).stdout,
|
|
742
|
-
stderr: firstFailure?.stderr ||
|
|
743
|
-
|
|
800
|
+
stderr: firstFailure?.stderr ||
|
|
801
|
+
lintSyntheticFailure?.stderr ||
|
|
802
|
+
traceError?.message ||
|
|
803
|
+
"frontend reverify failed",
|
|
804
|
+
failureCategory: firstFailure?.failureCategory ??
|
|
805
|
+
lintSyntheticFailure?.failureCategory ??
|
|
806
|
+
"invalid-output",
|
|
744
807
|
durationMs: Date.now() - started,
|
|
745
808
|
...{ commandResults },
|
|
746
809
|
};
|
|
747
810
|
}
|
|
748
811
|
return {
|
|
749
812
|
ok: true,
|
|
750
|
-
stdout:
|
|
813
|
+
stdout: lintAssessment?.status === "baseline-debt"
|
|
814
|
+
? "Frontend reverify bundle: pass with lint baseline-debt"
|
|
815
|
+
: "Frontend reverify bundle: pass",
|
|
751
816
|
stderr: "",
|
|
752
817
|
failureCategory: "success",
|
|
753
818
|
durationMs: Date.now() - started,
|
|
@@ -755,14 +820,22 @@ async function executeFrontendVerificationBundle(input, meta) {
|
|
|
755
820
|
};
|
|
756
821
|
}
|
|
757
822
|
const failureFacts = [];
|
|
758
|
-
if (firstFailure) {
|
|
823
|
+
if (firstFailure || lintSyntheticFailure) {
|
|
824
|
+
const failureStdout = firstFailure
|
|
825
|
+
? [firstFailure.command, firstFailure.stdout]
|
|
826
|
+
.filter(Boolean)
|
|
827
|
+
.join("\n")
|
|
828
|
+
: "";
|
|
759
829
|
failureFacts.push({
|
|
760
830
|
nodeId: input.task.id,
|
|
761
831
|
record: {
|
|
762
832
|
status: "FINISHED",
|
|
763
|
-
failureCategory: firstFailure
|
|
764
|
-
|
|
765
|
-
|
|
833
|
+
failureCategory: firstFailure?.failureCategory ??
|
|
834
|
+
lintSyntheticFailure?.failureCategory,
|
|
835
|
+
// Keep classification scoped to the failed command. Aggregate
|
|
836
|
+
// successful output may contain unrelated writeSet-like JSON.
|
|
837
|
+
stdout: failureStdout,
|
|
838
|
+
stderr: firstFailure?.stderr ?? lintSyntheticFailure?.stderr,
|
|
766
839
|
commandResults,
|
|
767
840
|
},
|
|
768
841
|
});
|
|
@@ -803,6 +876,68 @@ async function executeFrontendVerificationBundle(input, meta) {
|
|
|
803
876
|
};
|
|
804
877
|
}
|
|
805
878
|
}
|
|
879
|
+
async function executeFrontendLintBaseline(input, meta) {
|
|
880
|
+
const started = Date.now();
|
|
881
|
+
const shell = input.task.shell;
|
|
882
|
+
const baseline = shell.frontendLintBaseline;
|
|
883
|
+
const cwd = resolveShellCwd(input.cwd, shell.cwd);
|
|
884
|
+
let beforeStatus;
|
|
885
|
+
try {
|
|
886
|
+
beforeStatus = await readGitStatusPorcelain(input.cwd);
|
|
887
|
+
}
|
|
888
|
+
catch {
|
|
889
|
+
beforeStatus = undefined;
|
|
890
|
+
}
|
|
891
|
+
const results = [];
|
|
892
|
+
for (const command of baseline.lintCommands) {
|
|
893
|
+
const commandNumber = results.length + 1;
|
|
894
|
+
results.push(await executeShellCommand({
|
|
895
|
+
command,
|
|
896
|
+
cwd,
|
|
897
|
+
timeoutMs: shell.timeoutMs ?? DEFAULT_SHELL_TIMEOUT_MS,
|
|
898
|
+
envAllowlist: shell.envAllowlist,
|
|
899
|
+
dagRunMeta: { runDir: meta.runDir, runId: meta.runId },
|
|
900
|
+
outputArtifacts: {
|
|
901
|
+
stdoutPath: path.join(meta.runDir, input.task.id, "commands", `${commandNumber}.stdout.txt`),
|
|
902
|
+
stderrPath: path.join(meta.runDir, input.task.id, "commands", `${commandNumber}.stderr.txt`),
|
|
903
|
+
},
|
|
904
|
+
}));
|
|
905
|
+
}
|
|
906
|
+
let afterStatus;
|
|
907
|
+
try {
|
|
908
|
+
afterStatus = await readGitStatusPorcelain(input.cwd);
|
|
909
|
+
}
|
|
910
|
+
catch {
|
|
911
|
+
afterStatus = undefined;
|
|
912
|
+
}
|
|
913
|
+
try {
|
|
914
|
+
const materialized = await materializeFrontendLintBaseline({
|
|
915
|
+
runDir: meta.runDir,
|
|
916
|
+
workspaceRoot: input.cwd,
|
|
917
|
+
commands: baseline.lintCommands,
|
|
918
|
+
results,
|
|
919
|
+
worktreeChanged: beforeStatus === undefined ||
|
|
920
|
+
afterStatus === undefined ||
|
|
921
|
+
beforeStatus !== afterStatus,
|
|
922
|
+
});
|
|
923
|
+
return {
|
|
924
|
+
ok: true,
|
|
925
|
+
stdout: `Frontend lint baseline: ${materialized.artifact.status}\nArtifact: ${materialized.ref.path}\nDiagnostics: ${materialized.artifact.diagnostics.length}`,
|
|
926
|
+
stderr: materialized.artifact.reason ?? "",
|
|
927
|
+
failureCategory: "success",
|
|
928
|
+
durationMs: Date.now() - started,
|
|
929
|
+
};
|
|
930
|
+
}
|
|
931
|
+
catch (error) {
|
|
932
|
+
return {
|
|
933
|
+
ok: false,
|
|
934
|
+
stdout: "",
|
|
935
|
+
stderr: error instanceof Error ? error.message : String(error),
|
|
936
|
+
failureCategory: "invalid-output",
|
|
937
|
+
durationMs: Date.now() - started,
|
|
938
|
+
};
|
|
939
|
+
}
|
|
940
|
+
}
|
|
806
941
|
export async function executeDagShellNode(input, meta) {
|
|
807
942
|
const shell = input.task.shell;
|
|
808
943
|
if (shell?.frontendPrewriteGate) {
|
|
@@ -812,6 +947,7 @@ export async function executeDagShellNode(input, meta) {
|
|
|
812
947
|
runDir: meta.runDir,
|
|
813
948
|
config: shell.frontendPrewriteGate,
|
|
814
949
|
sourceBinding: meta.spec.sourceBinding,
|
|
950
|
+
workspaceRoot: input.cwd,
|
|
815
951
|
repoRoot: input.cwd,
|
|
816
952
|
});
|
|
817
953
|
return { ok: true, stdout: formatFrontendPrewriteGateStdout(result), stderr: "", failureCategory: "success", durationMs: Date.now() - started };
|
|
@@ -820,13 +956,20 @@ export async function executeDagShellNode(input, meta) {
|
|
|
820
956
|
return { ok: false, stdout: "", stderr: error instanceof Error ? error.message : String(error), failureCategory: "invalid-output", durationMs: Date.now() - started };
|
|
821
957
|
}
|
|
822
958
|
}
|
|
959
|
+
if (shell?.frontendLintBaseline) {
|
|
960
|
+
return executeFrontendLintBaseline(input, meta);
|
|
961
|
+
}
|
|
823
962
|
if (shell?.frontendVerificationBundle) {
|
|
824
963
|
return executeFrontendVerificationBundle(input, meta);
|
|
825
964
|
}
|
|
826
965
|
if (shell?.frontendReviewContext) {
|
|
827
966
|
const started = Date.now();
|
|
828
967
|
try {
|
|
829
|
-
const result = await runFrontendReviewContextGate({
|
|
968
|
+
const result = await runFrontendReviewContextGate({
|
|
969
|
+
runDir: meta.runDir,
|
|
970
|
+
workspaceRoot: input.cwd,
|
|
971
|
+
requireBaseline: shell.frontendReviewContext.requireBaseline,
|
|
972
|
+
});
|
|
830
973
|
return { ok: true, stdout: formatFrontendReviewContextStdout(result), stderr: "", failureCategory: "success", durationMs: Date.now() - started };
|
|
831
974
|
}
|
|
832
975
|
catch (error) {
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared openspec specification path predicates.
|
|
3
|
+
*
|
|
4
|
+
* Canonical frontend specification directories (case-sensitive) and extension
|
|
5
|
+
* allowlist.
|
|
6
|
+
* All consumers in candidate discovery, DAG schema, prewrite gate, and observe
|
|
7
|
+
* must use these predicates to ensure a single consistent scope definition.
|
|
8
|
+
*/
|
|
9
|
+
/** Canonical spec directories (case-sensitive, POSIX form). */
|
|
10
|
+
export const OPENSPEC_SPEC_DIRS = [
|
|
11
|
+
"openspec/schemas",
|
|
12
|
+
"openspec/project-specs",
|
|
13
|
+
"ai_workspace",
|
|
14
|
+
];
|
|
15
|
+
/** Allowed spec file extensions (case-insensitive). */
|
|
16
|
+
export const OPENSPEC_SPEC_EXT_RE = /\.(md|mdx|json|yaml|yml)$/i;
|
|
17
|
+
/**
|
|
18
|
+
* Returns `true` when `filePath` is inside one of the canonical frontend spec
|
|
19
|
+
* directories. `filePath` must be a repo-relative POSIX path (backslashes are
|
|
20
|
+
* normalised internally). Directory names are case-sensitive.
|
|
21
|
+
*/
|
|
22
|
+
export function isOpenspecSpecPath(filePath) {
|
|
23
|
+
const normalized = filePath.replaceAll("\\", "/");
|
|
24
|
+
for (const dir of OPENSPEC_SPEC_DIRS) {
|
|
25
|
+
if (normalized === dir || normalized.startsWith(dir + "/"))
|
|
26
|
+
return true;
|
|
27
|
+
}
|
|
28
|
+
return false;
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Returns `true` when `filename` (the basename only, not a full path) has an
|
|
32
|
+
* allowed frontend specification extension.
|
|
33
|
+
*/
|
|
34
|
+
export function isValidOpenspecExtension(filename) {
|
|
35
|
+
return OPENSPEC_SPEC_EXT_RE.test(filename);
|
|
36
|
+
}
|
|
37
|
+
/** Returns `true` for a supported file inside a canonical openspec spec dir. */
|
|
38
|
+
export function isOpenspecSpecFilePath(filePath) {
|
|
39
|
+
return (isOpenspecSpecPath(filePath) &&
|
|
40
|
+
isValidOpenspecExtension(filePath.replaceAll("\\", "/")));
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Returns `true` when free-form search input references a canonical frontend
|
|
44
|
+
* specification directory with exact lowercase directory names.
|
|
45
|
+
*/
|
|
46
|
+
export function isOpenspecSpecSearchTarget(value) {
|
|
47
|
+
const normalized = value.replaceAll("\\", "/");
|
|
48
|
+
return /(?:^|[^A-Za-z0-9_.-])(?:openspec\/(?:schemas|project-specs)|ai_workspace)(?:\/|$)/.test(normalized);
|
|
49
|
+
}
|
|
@@ -165,7 +165,17 @@ export function buildOperatorCapabilitiesDocument() {
|
|
|
165
165
|
resultSchemaVersion: 1,
|
|
166
166
|
envelopeSchemaVersion: 1,
|
|
167
167
|
requiredErrorCodes: ["INVALID_INPUT", "OPERATION_NEEDS_RECONCILE"],
|
|
168
|
-
description: "Repo doctor
|
|
168
|
+
description: "Repo health doctor with machine-readable report and read-only context audit (--context).",
|
|
169
|
+
},
|
|
170
|
+
{
|
|
171
|
+
action: "statsContext",
|
|
172
|
+
cli: "loop-agent stats context --last 50 --json",
|
|
173
|
+
kind: "read",
|
|
174
|
+
inputSchemaVersion: 1,
|
|
175
|
+
resultSchemaVersion: 1,
|
|
176
|
+
envelopeSchemaVersion: 1,
|
|
177
|
+
requiredErrorCodes: ["INVALID_INPUT"],
|
|
178
|
+
description: "Bounded skill-resolution / node-outcome telemetry over recent DAG runs (not compliance rates).",
|
|
169
179
|
},
|
|
170
180
|
{
|
|
171
181
|
action: "dagReport",
|
|
@@ -39,6 +39,8 @@ export function openConsoleAppData(options) {
|
|
|
39
39
|
staged: path.join(repoNamespace, "staged"),
|
|
40
40
|
assessments: path.join(repoNamespace, "assessments"),
|
|
41
41
|
interviews: path.join(repoNamespace, "interviews"),
|
|
42
|
+
chats: path.join(repoNamespace, "chats"),
|
|
43
|
+
chatSessions: path.join(repoNamespace, "chat-sessions"),
|
|
42
44
|
};
|
|
43
45
|
for (const dir of [
|
|
44
46
|
paths.drafts,
|
|
@@ -47,6 +49,8 @@ export function openConsoleAppData(options) {
|
|
|
47
49
|
paths.staged,
|
|
48
50
|
paths.assessments,
|
|
49
51
|
paths.interviews,
|
|
52
|
+
paths.chats,
|
|
53
|
+
paths.chatSessions,
|
|
50
54
|
]) {
|
|
51
55
|
ensureSecureDir(dir);
|
|
52
56
|
}
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Operator Chat — instruction skills whitelist (design §7.5 / plan D1).
|
|
3
|
+
*
|
|
4
|
+
* Hard rules (plan D1):
|
|
5
|
+
* - skills are READ-ONLY text context injected into the Chat system prompt;
|
|
6
|
+
* - skills are NEVER registered as executable tools;
|
|
7
|
+
* - the whitelist is a COMPILE-TIME constant (no runtime dir scan, no env override);
|
|
8
|
+
* - arbitrary pi extension / package tool / user-level ~/.pi/agent skill is
|
|
9
|
+
* never discovered or loaded (V12);
|
|
10
|
+
* - project `.agents/skills/` (init projection) is NOT injected (prevents
|
|
11
|
+
* target-repo injecting write instructions).
|
|
12
|
+
*
|
|
13
|
+
* Only the loop-agent repo's own audited skills are eligible. Workflow-specific
|
|
14
|
+
* skills are conditional on taskKind/workflow and loaded on demand (not in MVP
|
|
15
|
+
* operator-context default set).
|
|
16
|
+
*/
|
|
17
|
+
import { readFile } from "node:fs/promises";
|
|
18
|
+
import path from "node:path";
|
|
19
|
+
/**
|
|
20
|
+
* Operator-context instruction skills (plan D1, "operator-context 允许").
|
|
21
|
+
* These are read-only methodology skills. Loaded as text context for every
|
|
22
|
+
* Chat session by default.
|
|
23
|
+
*
|
|
24
|
+
* MUST stay a compile-time constant. Adding a skill requires an ADR / plan note.
|
|
25
|
+
*/
|
|
26
|
+
export const OPERATOR_CHAT_ALLOWED_INSTRUCTION_SKILLS = Object.freeze([
|
|
27
|
+
"grill-me",
|
|
28
|
+
"systematic-debugging",
|
|
29
|
+
"code-review-core",
|
|
30
|
+
"codebase-scout",
|
|
31
|
+
"verification-before-completion",
|
|
32
|
+
"requesting-code-review",
|
|
33
|
+
"analyze-product-requirements",
|
|
34
|
+
"analyze-product-dependencies",
|
|
35
|
+
"using-git-worktrees",
|
|
36
|
+
]);
|
|
37
|
+
/**
|
|
38
|
+
* Meta / governance skills that must NEVER be injected into Chat context
|
|
39
|
+
* (plan D1 "元 / 治理 skill"). Injecting these would let Chat rewrite its own
|
|
40
|
+
* governance. Listed explicitly so accidental allowlist edits are caught.
|
|
41
|
+
*/
|
|
42
|
+
export const OPERATOR_CHAT_DENIED_INSTRUCTION_SKILLS = Object.freeze([
|
|
43
|
+
"loop-agent",
|
|
44
|
+
"agent-worker",
|
|
45
|
+
"init-capability-evolution",
|
|
46
|
+
"ai-engineering-context",
|
|
47
|
+
"grill-with-docs",
|
|
48
|
+
]);
|
|
49
|
+
/**
|
|
50
|
+
* Workflow-specific skills (plan D1 "workflow-specific 允许(条件)").
|
|
51
|
+
* Only injected when the corresponding taskKind / workflow is active.
|
|
52
|
+
* NOT loaded by default in MVP Chat.
|
|
53
|
+
*/
|
|
54
|
+
export const OPERATOR_CHAT_WORKFLOW_CONDITIONAL_SKILLS = Object.freeze([
|
|
55
|
+
"frontend-implementation",
|
|
56
|
+
"frontend-review",
|
|
57
|
+
"frontend-verification",
|
|
58
|
+
"frontend-design-review",
|
|
59
|
+
"test-driven-development",
|
|
60
|
+
"webapp-testing",
|
|
61
|
+
"playwright-cli",
|
|
62
|
+
"playwright-cli-case-generator",
|
|
63
|
+
"browser-tools",
|
|
64
|
+
]);
|
|
65
|
+
const ALLOWED = new Set(OPERATOR_CHAT_ALLOWED_INSTRUCTION_SKILLS.map((s) => s.toLowerCase()));
|
|
66
|
+
const DENIED = new Set(OPERATOR_CHAT_DENIED_INSTRUCTION_SKILLS.map((s) => s.toLowerCase()));
|
|
67
|
+
const CONDITIONAL = new Set(OPERATOR_CHAT_WORKFLOW_CONDITIONAL_SKILLS.map((s) => s.toLowerCase()));
|
|
68
|
+
export function isOperatorChatInstructionSkillAllowed(name) {
|
|
69
|
+
return ALLOWED.has(name.trim().toLowerCase());
|
|
70
|
+
}
|
|
71
|
+
export function isOperatorChatInstructionSkillDenied(name) {
|
|
72
|
+
return DENIED.has(name.trim().toLowerCase());
|
|
73
|
+
}
|
|
74
|
+
export function isOperatorChatInstructionSkillConditional(name) {
|
|
75
|
+
return CONDITIONAL.has(name.trim().toLowerCase());
|
|
76
|
+
}
|
|
77
|
+
export function classifyInstructionSkill(name, options) {
|
|
78
|
+
const n = name.trim().toLowerCase();
|
|
79
|
+
if (DENIED.has(n)) {
|
|
80
|
+
return { ok: false, reason: "denied-governance", name };
|
|
81
|
+
}
|
|
82
|
+
if (ALLOWED.has(n)) {
|
|
83
|
+
return { ok: true, kind: "operator-context", name };
|
|
84
|
+
}
|
|
85
|
+
if (CONDITIONAL.has(n)) {
|
|
86
|
+
const active = options?.activeWorkflowSkills ?? [];
|
|
87
|
+
const activeLower = new Set(active.map((s) => s.toLowerCase()));
|
|
88
|
+
if (activeLower.has(n)) {
|
|
89
|
+
// Conditional skill is allowed only when explicitly activated by workflow.
|
|
90
|
+
// For MVP, conditional skills are not auto-injected; callers must opt in.
|
|
91
|
+
return { ok: false, reason: "conditional-not-active", name };
|
|
92
|
+
}
|
|
93
|
+
return { ok: false, reason: "conditional-not-active", name };
|
|
94
|
+
}
|
|
95
|
+
return { ok: false, reason: "unknown", name };
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* Parse a SKILL.md frontmatter description (first `description:` line) and
|
|
99
|
+
* return the body text (frontmatter stripped). Tolerant of missing frontmatter.
|
|
100
|
+
*/
|
|
101
|
+
export function parseSkillFrontmatter(raw) {
|
|
102
|
+
let description = "";
|
|
103
|
+
let bodyText = raw;
|
|
104
|
+
const hasFrontmatter = raw.trimStart().startsWith("---");
|
|
105
|
+
if (hasFrontmatter) {
|
|
106
|
+
const lines = raw.split(/\r?\n/);
|
|
107
|
+
// find closing ---
|
|
108
|
+
let closeIdx = -1;
|
|
109
|
+
for (let i = 1; i < lines.length; i++) {
|
|
110
|
+
if (lines[i].trim() === "---") {
|
|
111
|
+
closeIdx = i;
|
|
112
|
+
break;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
if (closeIdx > 0) {
|
|
116
|
+
const fm = lines.slice(1, closeIdx).join("\n");
|
|
117
|
+
bodyText = lines.slice(closeIdx + 1).join("\n").trim();
|
|
118
|
+
const descMatch = fm.match(/^description:\s*(.+)$/m);
|
|
119
|
+
if (descMatch) {
|
|
120
|
+
description = descMatch[1].trim().replace(/^["']|["']$/g, "");
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
else {
|
|
125
|
+
bodyText = raw.trim();
|
|
126
|
+
}
|
|
127
|
+
return { description, bodyText };
|
|
128
|
+
}
|
|
129
|
+
/**
|
|
130
|
+
* Load the operator-context instruction skills from the loop-agent repo's own
|
|
131
|
+
* `skills/` directory and return their text bodies for system-prompt injection.
|
|
132
|
+
*
|
|
133
|
+
* Security: only names in OPERATOR_CHAT_ALLOWED_INSTRUCTION_SKILLS are read;
|
|
134
|
+
* any other path is rejected before filesystem access (safe-side).
|
|
135
|
+
*
|
|
136
|
+
* @param skillsDir absolute path to the loop-agent repo `skills/` directory
|
|
137
|
+
* (source repo / npm pack asset, NOT target project `.agents/skills/`).
|
|
138
|
+
* @param names override list (tests); defaults to the full allowlist.
|
|
139
|
+
*/
|
|
140
|
+
export async function loadOperatorChatInstructionSkills(skillsDir, names) {
|
|
141
|
+
const selected = names ?? OPERATOR_CHAT_ALLOWED_INSTRUCTION_SKILLS;
|
|
142
|
+
const loaded = [];
|
|
143
|
+
const failures = [];
|
|
144
|
+
const skipped = [];
|
|
145
|
+
for (const rawName of selected) {
|
|
146
|
+
const name = rawName.trim();
|
|
147
|
+
if (!isOperatorChatInstructionSkillAllowed(name)) {
|
|
148
|
+
skipped.push(name);
|
|
149
|
+
continue;
|
|
150
|
+
}
|
|
151
|
+
// Defense in depth: reject path traversal / governance skills even if
|
|
152
|
+
// an attacker managed to mutate the allowlist at runtime.
|
|
153
|
+
if (isOperatorChatInstructionSkillDenied(name)) {
|
|
154
|
+
skipped.push(name);
|
|
155
|
+
continue;
|
|
156
|
+
}
|
|
157
|
+
const skillPath = path.join(skillsDir, name, "SKILL.md");
|
|
158
|
+
const resolved = path.resolve(skillPath);
|
|
159
|
+
const resolvedSkillsDir = path.resolve(skillsDir);
|
|
160
|
+
if (!resolved.startsWith(resolvedSkillsDir + path.sep)) {
|
|
161
|
+
failures.push({
|
|
162
|
+
name,
|
|
163
|
+
message: "path escapes skills directory",
|
|
164
|
+
});
|
|
165
|
+
continue;
|
|
166
|
+
}
|
|
167
|
+
try {
|
|
168
|
+
const raw = await readFile(resolved, "utf8");
|
|
169
|
+
const { description, bodyText } = parseSkillFrontmatter(raw);
|
|
170
|
+
loaded.push({
|
|
171
|
+
name,
|
|
172
|
+
description,
|
|
173
|
+
bodyText,
|
|
174
|
+
charCount: bodyText.length,
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
catch (error) {
|
|
178
|
+
failures.push({
|
|
179
|
+
name,
|
|
180
|
+
message: error instanceof Error ? error.message : String(error),
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
return { loaded, failures, skipped };
|
|
185
|
+
}
|
|
186
|
+
/** Hard cap on total injected instruction-skill chars (token budget guard). */
|
|
187
|
+
export const OPERATOR_CHAT_INSTRUCTION_CHAR_BUDGET = 24_000;
|
|
188
|
+
/**
|
|
189
|
+
* Compose the system-prompt fragment listing loaded instruction skills.
|
|
190
|
+
* Each skill is rendered as a bounded text block. If total chars exceed the
|
|
191
|
+
* budget, later skills are truncated (operator-context set is small enough
|
|
192
|
+
* that truncation should not trigger in practice, but the cap is enforced).
|
|
193
|
+
*/
|
|
194
|
+
export function composeInstructionSkillsPrompt(skills, budget = OPERATOR_CHAT_INSTRUCTION_CHAR_BUDGET) {
|
|
195
|
+
if (skills.length === 0)
|
|
196
|
+
return "";
|
|
197
|
+
const lines = [
|
|
198
|
+
"## Operator methodology context (read-only instruction skills)",
|
|
199
|
+
"",
|
|
200
|
+
"These skills are READ-ONLY context. You may reference their methodology when reasoning, but you CANNOT execute them. All actions must go through the registered operator tools.",
|
|
201
|
+
"",
|
|
202
|
+
];
|
|
203
|
+
let used = 0;
|
|
204
|
+
for (const skill of skills) {
|
|
205
|
+
const header = `\n### ${skill.name}${skill.description ? ` — ${skill.description}` : ""}\n`;
|
|
206
|
+
const remaining = budget - used - header.length;
|
|
207
|
+
if (remaining <= 0) {
|
|
208
|
+
lines.push(`_[${skill.name}: omitted, char budget exhausted]_`);
|
|
209
|
+
break;
|
|
210
|
+
}
|
|
211
|
+
const slice = skill.bodyText.slice(0, Math.max(0, remaining));
|
|
212
|
+
lines.push(header);
|
|
213
|
+
lines.push(slice);
|
|
214
|
+
used += header.length + slice.length;
|
|
215
|
+
}
|
|
216
|
+
return lines.join("\n");
|
|
217
|
+
}
|