@tea-agent/loop-agent 0.16.26 → 0.17.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.
Files changed (104) hide show
  1. package/CHANGELOG.md +45 -1
  2. package/README.md +14 -3
  3. package/dist/cli/command-definitions.js +43 -0
  4. package/dist/cli/program.js +26 -0
  5. package/dist/commands/dag-approve.js +4 -0
  6. package/dist/commands/dag-resume.js +1 -0
  7. package/dist/commands/dag-validate.js +6 -0
  8. package/dist/commands/operator.js +44 -0
  9. package/dist/commands/task-contract.js +271 -0
  10. package/dist/executors/dag-pi-executor.js +55 -7
  11. package/dist/executors/pi-executor.js +206 -13
  12. package/dist/executors/pi-sdk-executor.js +21 -6
  13. package/dist/executors/shell-executor.js +55 -8
  14. package/dist/executors/shell-presets.js +16 -3
  15. package/dist/shared/operator/capabilities.js +255 -0
  16. package/dist/shared/operator/envelope.js +59 -0
  17. package/dist/shared/operator/index.js +4 -0
  18. package/dist/shared/operator/registry.js +38 -0
  19. package/dist/shared/operator/types.js +5 -0
  20. package/dist/task/contract/adopt.js +166 -0
  21. package/dist/task/contract/apply.js +326 -0
  22. package/dist/task/contract/canonicalize.js +60 -0
  23. package/dist/task/contract/constants.js +29 -0
  24. package/dist/task/contract/diff.js +177 -0
  25. package/dist/task/contract/hash.js +42 -0
  26. package/dist/task/contract/import-revision.js +96 -0
  27. package/dist/task/contract/index.js +17 -0
  28. package/dist/task/contract/journal.js +155 -0
  29. package/dist/task/contract/lock.js +153 -0
  30. package/dist/task/contract/observe.js +296 -0
  31. package/dist/task/contract/paths.js +19 -0
  32. package/dist/task/contract/project.js +170 -0
  33. package/dist/task/contract/recover.js +312 -0
  34. package/dist/task/contract/request-ledger.js +37 -0
  35. package/dist/task/contract/schema.js +151 -0
  36. package/dist/task/contract/transaction.js +160 -0
  37. package/dist/task/contract/types.js +1 -0
  38. package/dist/task/contract/validate-draft.js +106 -0
  39. package/dist/task/index.js +3 -0
  40. package/dist/task/operator/capabilities.js +6 -0
  41. package/dist/task/operator/envelope.js +2 -0
  42. package/dist/task/operator/index.js +5 -0
  43. package/dist/task/operator/registry.js +2 -0
  44. package/dist/task/operator/types.js +1 -0
  45. package/dist/task/runtime.js +5 -1
  46. package/dist/task/source-references.js +7 -0
  47. package/dist/worker/cli.js +150 -32
  48. package/dist/worker/console/app-data.js +185 -0
  49. package/dist/worker/console/dag-confirmation.js +313 -0
  50. package/dist/worker/console/doctor.js +169 -0
  51. package/dist/worker/console/draft-store.js +80 -0
  52. package/dist/worker/console/index.js +15 -0
  53. package/dist/worker/console/interview/assessment.js +67 -0
  54. package/dist/worker/console/interview/session.js +100 -0
  55. package/dist/worker/console/interview/tools.js +109 -0
  56. package/dist/worker/console/loopback.js +16 -0
  57. package/dist/worker/console/observe-health-match.js +174 -0
  58. package/dist/worker/console/observe-link.js +33 -0
  59. package/dist/worker/console/operation-runner.js +166 -0
  60. package/dist/worker/console/operation-sse.js +158 -0
  61. package/dist/worker/console/operation-store.js +147 -0
  62. package/dist/worker/console/operator-actions.js +769 -0
  63. package/dist/worker/console/pi-readiness.js +266 -0
  64. package/dist/worker/console/recovery-cta.js +133 -0
  65. package/dist/worker/console/repo-fingerprint.js +29 -0
  66. package/dist/worker/console/resource-loader.js +95 -0
  67. package/dist/worker/console/routes.js +368 -0
  68. package/dist/worker/console/security.js +126 -0
  69. package/dist/worker/console/server.js +149 -0
  70. package/dist/worker/console/sibling-controller.js +28 -0
  71. package/dist/worker/console/static/assets/index-BEIdBogJ.js +9 -0
  72. package/dist/worker/console/static/assets/index-Rt0TqimP.css +1 -0
  73. package/dist/worker/console/static/index.html +13 -0
  74. package/dist/worker/console/vite.config.js +27 -0
  75. package/dist/worker/materialize/harness-task-materializer.js +34 -0
  76. package/dist/worker/observe/health.js +57 -0
  77. package/dist/worker/observe/routes.js +16 -9
  78. package/dist/workflows/dag/backend-test-analysis-contract.js +34 -9
  79. package/dist/workflows/dag/dynamic-runtime/shared.js +1 -0
  80. package/dist/workflows/dag/frontend-repair.js +1 -10
  81. package/dist/workflows/dag/init-hybrid.js +269 -116
  82. package/dist/workflows/dag/node-execution.js +25 -1
  83. package/dist/workflows/dag/retry-policy.js +16 -1
  84. package/dist/workflows/dag/runner.js +9 -0
  85. package/dist/workflows/dag/task-contract-binding.js +138 -0
  86. package/dist/workflows/dag/types.js +74 -10
  87. package/dist/workflows/dag/validate.js +25 -7
  88. package/docs/README.md +2 -0
  89. package/docs/architecture/evolution.md +2 -0
  90. package/docs/architecture/system-overview.md +6 -0
  91. package/docs/architecture/worker-and-feature.md +7 -0
  92. package/docs/templates/agent-dag.schema.json +49 -2
  93. package/docs/templates/backend-test-dag.classify.prompt.md +1 -1
  94. package/docs/templates/backend-test-dag.generate-pytest.prompt.md +5 -5
  95. package/docs/templates/backend-test-dag.json +26 -154
  96. package/docs/templates/backend-test-dag.retrospect.prompt.md +1 -1
  97. package/docs/templates/backend-test-dag.review-cases.prompt.md +2 -2
  98. package/package.json +8 -2
  99. package/skills/agent-worker/SKILL.md +1 -0
  100. package/skills/agent-worker/references/agent-worker-operator.md +3 -2
  101. package/skills/frontend-design-review/SKILL.md +25 -16
  102. package/skills/frontend-implementation/references/node-contracts.md +5 -5
  103. package/skills/loop-agent/references/command-reference.md +48 -1
  104. package/skills/loop-agent/references/hybrid-dag.md +4 -4
@@ -0,0 +1,96 @@
1
+ /**
2
+ * After managed import-prd updates source-manifest / task.json, bump contract revision.
3
+ * import-prd owns reference bytes; this only refreshes TaskContractRefV1.
4
+ */
5
+ import { CANONICALIZER_VERSION, PROJECTION_VERSION, TASK_CONFIG_SCHEMA_VERSION, TASK_CONTRACT_REF_SCHEMA_VERSION, } from "./constants.js";
6
+ import { computeCanonicalHash, computeSourceHashes, computeTaskConfigSha256, } from "./hash.js";
7
+ import { acquireTaskContractLock } from "./lock.js";
8
+ import { loadObservedTaskBytes, logicalRevisionForState, observeTaskContractState, } from "./observe.js";
9
+ import { serializeTaskConfigForDisk } from "./project.js";
10
+ import { finalizeTransaction, runJournaledContractCommit } from "./transaction.js";
11
+ import { parseTaskConfigForRuntime } from "../runtime.js";
12
+ export async function refreshManagedContractAfterImport(input) {
13
+ const state = await observeTaskContractState(input.repoRoot, input.taskId);
14
+ // Only refresh when already managed (or externally-modified with a ref).
15
+ // legacy-unversioned: first apply/adopt will capture manifest.
16
+ if (!state.ref) {
17
+ return null;
18
+ }
19
+ if (state.effectiveStatus === "transaction-incomplete") {
20
+ throw new Error(`TRANSACTION_INCOMPLETE: cannot refresh contract after import-prd for ${input.taskId}; run task contract recover first`);
21
+ }
22
+ const requestId = input.requestId ??
23
+ `import-prd-${Date.now()}-${Math.random().toString(16).slice(2, 10)}`;
24
+ const requestPayloadSha256 = "0".repeat(64); // import path uses synthetic payload
25
+ const lock = await acquireTaskContractLock({
26
+ repoRoot: input.repoRoot,
27
+ taskId: input.taskId,
28
+ requestId,
29
+ operation: "import-prd-revision",
30
+ });
31
+ try {
32
+ const state2 = await observeTaskContractState(input.repoRoot, input.taskId);
33
+ if (!state2.ref)
34
+ return null;
35
+ const currentRevision = logicalRevisionForState(state2);
36
+ const bytes = await loadObservedTaskBytes(input.repoRoot, input.taskId);
37
+ if (bytes.requirementBytes === null ||
38
+ bytes.constraintsBytes === null ||
39
+ bytes.taskConfigRaw === null) {
40
+ throw new Error("import-prd revision refresh requires 需求.md, 执行约束.md, and task.json");
41
+ }
42
+ const fullConfig = parseTaskConfigForRuntime(bytes.taskConfigRaw);
43
+ const taskConfigContent = serializeTaskConfigForDisk(fullConfig);
44
+ const sourceHashes = computeSourceHashes({
45
+ requirementBytes: bytes.requirementBytes,
46
+ constraintsBytes: bytes.constraintsBytes,
47
+ referenceManifestBytes: bytes.referenceManifestBytes,
48
+ });
49
+ const taskConfigSha256 = computeTaskConfigSha256(fullConfig);
50
+ const canonicalHash = computeCanonicalHash({
51
+ sourceHashes,
52
+ taskConfigSha256,
53
+ });
54
+ const nextRevision = currentRevision + 1;
55
+ const nextRef = {
56
+ schemaVersion: TASK_CONTRACT_REF_SCHEMA_VERSION,
57
+ taskId: input.taskId,
58
+ revision: nextRevision,
59
+ projectionVersion: PROJECTION_VERSION,
60
+ canonicalizerVersion: CANONICALIZER_VERSION,
61
+ taskConfigSchemaVersion: TASK_CONFIG_SCHEMA_VERSION,
62
+ sourceHashes,
63
+ taskConfigSha256,
64
+ canonicalHash,
65
+ updatedAt: new Date().toISOString(),
66
+ lastRequestId: requestId,
67
+ };
68
+ const expectedHash = state2.observedCanonicalHash ?? state2.ref.canonicalHash;
69
+ const { txId } = await runJournaledContractCommit({
70
+ repoRoot: input.repoRoot,
71
+ taskId: input.taskId,
72
+ operation: "import-prd-revision",
73
+ requestId,
74
+ requestPayloadSha256,
75
+ expectedRevision: currentRevision,
76
+ expectedObservedCanonicalHash: expectedHash,
77
+ nextRevision,
78
+ fencingToken: lock.record.fencingToken,
79
+ targets: {
80
+ requirementContent: bytes.requirementBytes,
81
+ constraintsContent: bytes.constraintsBytes,
82
+ taskConfigContent,
83
+ contractRef: nextRef,
84
+ },
85
+ });
86
+ await finalizeTransaction({
87
+ repoRoot: input.repoRoot,
88
+ taskId: input.taskId,
89
+ txId,
90
+ });
91
+ return nextRef;
92
+ }
93
+ finally {
94
+ await lock.release();
95
+ }
96
+ }
@@ -0,0 +1,17 @@
1
+ export * from "./constants.js";
2
+ export * from "./types.js";
3
+ export * from "./canonicalize.js";
4
+ export * from "./hash.js";
5
+ export * from "./schema.js";
6
+ export * from "./project.js";
7
+ export * from "./paths.js";
8
+ export * from "./observe.js";
9
+ export * from "./lock.js";
10
+ export * from "./journal.js";
11
+ export * from "./transaction.js";
12
+ export * from "./request-ledger.js";
13
+ // apply.ts owns diffTaskContract / validateDraftForTask / applyTaskContract surface
14
+ export * from "./apply.js";
15
+ export * from "./adopt.js";
16
+ export * from "./recover.js";
17
+ export * from "./import-revision.js";
@@ -0,0 +1,155 @@
1
+ import { copyFile, mkdir, readFile, readdir, rename, unlink, writeFile, } from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { writeJsonAtomic, writeTextAtomic } from "../../infrastructure/harness/atomic-write.js";
4
+ import { getTaskPaths } from "../runtime.js";
5
+ export function getTransactionPaths(repoRoot, taskId, txId) {
6
+ const { transactionsDir } = getTaskPaths(repoRoot, taskId);
7
+ const root = path.join(transactionsDir, txId);
8
+ return {
9
+ txId,
10
+ root,
11
+ statePath: path.join(root, "state.json"),
12
+ intentPath: path.join(root, "intent.json"),
13
+ commitPath: path.join(root, "commit.json"),
14
+ stagedDir: path.join(root, "staged"),
15
+ originalsDir: path.join(root, "originals"),
16
+ };
17
+ }
18
+ export async function ensureTransactionDirs(txPaths) {
19
+ await mkdir(txPaths.stagedDir, { recursive: true });
20
+ await mkdir(txPaths.originalsDir, { recursive: true });
21
+ }
22
+ export async function writeTransactionState(txPaths, phase, error) {
23
+ const state = {
24
+ schemaVersion: 1,
25
+ txId: txPaths.txId,
26
+ phase,
27
+ updatedAt: new Date().toISOString(),
28
+ ...(error ? { error } : {}),
29
+ };
30
+ await writeJsonAtomic(txPaths.statePath, state);
31
+ }
32
+ export async function readTransactionState(txPaths) {
33
+ try {
34
+ const raw = await readFile(txPaths.statePath, "utf-8");
35
+ return JSON.parse(raw);
36
+ }
37
+ catch (error) {
38
+ if (error &&
39
+ typeof error === "object" &&
40
+ "code" in error &&
41
+ error.code === "ENOENT") {
42
+ return null;
43
+ }
44
+ throw error;
45
+ }
46
+ }
47
+ export async function writeTransactionIntent(txPaths, intent) {
48
+ await writeJsonAtomic(txPaths.intentPath, intent);
49
+ }
50
+ export async function readTransactionIntent(txPaths) {
51
+ try {
52
+ const raw = await readFile(txPaths.intentPath, "utf-8");
53
+ return JSON.parse(raw);
54
+ }
55
+ catch (error) {
56
+ if (error &&
57
+ typeof error === "object" &&
58
+ "code" in error &&
59
+ error.code === "ENOENT") {
60
+ return null;
61
+ }
62
+ throw error;
63
+ }
64
+ }
65
+ export async function writeCommitMarker(txPaths, commit) {
66
+ await writeJsonAtomic(txPaths.commitPath, {
67
+ schemaVersion: 1,
68
+ ...commit,
69
+ });
70
+ }
71
+ export async function commitMarkerExists(txPaths) {
72
+ try {
73
+ await readFile(txPaths.commitPath, "utf-8");
74
+ return true;
75
+ }
76
+ catch {
77
+ return false;
78
+ }
79
+ }
80
+ export async function stageTextFile(txPaths, relativePath, content, _repoRoot) {
81
+ const target = path.join(txPaths.stagedDir, relativePath);
82
+ await mkdir(path.dirname(target), { recursive: true });
83
+ await writeTextAtomic(target, content);
84
+ return target;
85
+ }
86
+ export async function stageJsonFile(txPaths, relativePath, value, _repoRoot) {
87
+ const target = path.join(txPaths.stagedDir, relativePath);
88
+ await mkdir(path.dirname(target), { recursive: true });
89
+ await writeJsonAtomic(target, value);
90
+ return target;
91
+ }
92
+ export async function backupOriginalIfExists(txPaths, absoluteTarget, relativePath) {
93
+ const backupPath = path.join(txPaths.originalsDir, relativePath);
94
+ await mkdir(path.dirname(backupPath), { recursive: true });
95
+ try {
96
+ const content = await readFile(absoluteTarget, "utf-8");
97
+ await writeTextAtomic(backupPath, content);
98
+ return { existed: true, content };
99
+ }
100
+ catch (error) {
101
+ if (error &&
102
+ typeof error === "object" &&
103
+ "code" in error &&
104
+ error.code === "ENOENT") {
105
+ await writeTextAtomic(`${backupPath}.missing`, "missing\n");
106
+ return { existed: false, content: null };
107
+ }
108
+ throw error;
109
+ }
110
+ }
111
+ async function replaceFile(targetPath, content) {
112
+ await mkdir(path.dirname(targetPath), { recursive: true });
113
+ const tempPath = `${targetPath}.${process.pid}.tmp`;
114
+ await writeFile(tempPath, content, "utf-8");
115
+ try {
116
+ await rename(tempPath, targetPath);
117
+ }
118
+ catch {
119
+ await copyFile(tempPath, targetPath);
120
+ await unlink(tempPath).catch(() => { });
121
+ }
122
+ }
123
+ export async function applyStagedToTarget(txPaths, relativePath, absoluteTarget) {
124
+ const stagedPath = path.join(txPaths.stagedDir, relativePath);
125
+ const content = await readFile(stagedPath, "utf-8");
126
+ await replaceFile(absoluteTarget, content);
127
+ }
128
+ export async function restoreOriginal(txPaths, relativePath, absoluteTarget, existedBefore) {
129
+ const backupPath = path.join(txPaths.originalsDir, relativePath);
130
+ const missingMarker = `${backupPath}.missing`;
131
+ if (!existedBefore) {
132
+ await unlink(absoluteTarget).catch(() => { });
133
+ return;
134
+ }
135
+ try {
136
+ await readFile(missingMarker, "utf-8");
137
+ await unlink(absoluteTarget).catch(() => { });
138
+ return;
139
+ }
140
+ catch {
141
+ // not missing
142
+ }
143
+ const content = await readFile(backupPath, "utf-8");
144
+ await replaceFile(absoluteTarget, content);
145
+ }
146
+ export async function listTransactionIds(repoRoot, taskId) {
147
+ const { transactionsDir } = getTaskPaths(repoRoot, taskId);
148
+ try {
149
+ const entries = await readdir(transactionsDir, { withFileTypes: true });
150
+ return entries.filter((e) => e.isDirectory()).map((e) => e.name);
151
+ }
152
+ catch {
153
+ return [];
154
+ }
155
+ }
@@ -0,0 +1,153 @@
1
+ import { randomBytes } from "node:crypto";
2
+ import { mkdir, readFile, rename, unlink, writeFile } from "node:fs/promises";
3
+ import path from "node:path";
4
+ import { getTaskContractPaths } from "./paths.js";
5
+ function processStartIdentity() {
6
+ return `${process.pid}:${process.ppid ?? 0}:${process.env.LOOP_AGENT_PROCESS_START ?? "na"}`;
7
+ }
8
+ async function readLock(lockPath) {
9
+ try {
10
+ const raw = await readFile(lockPath, "utf-8");
11
+ return JSON.parse(raw);
12
+ }
13
+ catch (error) {
14
+ if (error &&
15
+ typeof error === "object" &&
16
+ "code" in error &&
17
+ error.code === "ENOENT") {
18
+ return null;
19
+ }
20
+ throw error;
21
+ }
22
+ }
23
+ function isProcessAlive(pid) {
24
+ try {
25
+ process.kill(pid, 0);
26
+ return true;
27
+ }
28
+ catch {
29
+ return false;
30
+ }
31
+ }
32
+ /**
33
+ * Acquire per-task contract lock with fencing token.
34
+ * Repo-wide mutation lease (when needed) must be taken *before* calling this.
35
+ */
36
+ export async function acquireTaskContractLock(input) {
37
+ const paths = getTaskContractPaths(input.repoRoot, input.taskId);
38
+ await mkdir(paths.taskDir, { recursive: true });
39
+ const lockPath = paths.lockPath;
40
+ const fencingToken = randomBytes(16).toString("hex");
41
+ const ownerPid = process.pid;
42
+ const ownerStartIdentity = processStartIdentity();
43
+ const now = new Date().toISOString();
44
+ const record = {
45
+ schemaVersion: 1,
46
+ taskId: input.taskId,
47
+ fencingToken,
48
+ ownerPid,
49
+ ownerStartIdentity,
50
+ acquiredAt: now,
51
+ heartbeatAt: now,
52
+ requestId: input.requestId,
53
+ operation: input.operation,
54
+ };
55
+ const tempPath = `${lockPath}.${ownerPid}.${fencingToken}.tmp`;
56
+ await writeFile(tempPath, `${JSON.stringify(record, null, 2)}\n`, "utf-8");
57
+ try {
58
+ const existing = await readLock(lockPath);
59
+ if (existing) {
60
+ // Never steal from a still-alive owner. Heartbeat age alone is not enough:
61
+ // long journaled applies do not refresh heartbeat continuously.
62
+ const ownerAlive = isProcessAlive(existing.ownerPid);
63
+ if (ownerAlive) {
64
+ await unlink(tempPath).catch(() => { });
65
+ const err = new Error(`task contract lock held by pid=${existing.ownerPid} token=${existing.fencingToken} requestId=${existing.requestId ?? ""}`);
66
+ err.code = "LEASE_HELD";
67
+ throw err;
68
+ }
69
+ // Owner PID is dead — reclaim after optional grace (default 0 for dead owners).
70
+ const staleMs = input.staleMs ?? 0;
71
+ const heartbeat = Date.parse(existing.heartbeatAt);
72
+ const oldEnough = !Number.isFinite(heartbeat) || Date.now() - heartbeat >= staleMs;
73
+ if (!oldEnough) {
74
+ await unlink(tempPath).catch(() => { });
75
+ const err = new Error(`task contract lock held by dead pid=${existing.ownerPid} but not yet reclaimable`);
76
+ err.code = "LEASE_HELD";
77
+ throw err;
78
+ }
79
+ await unlink(lockPath).catch(() => { });
80
+ }
81
+ await rename(tempPath, lockPath);
82
+ }
83
+ catch (error) {
84
+ await unlink(tempPath).catch(() => { });
85
+ if (error &&
86
+ typeof error === "object" &&
87
+ "code" in error &&
88
+ error.code === "LEASE_HELD") {
89
+ throw error;
90
+ }
91
+ const existing = await readLock(lockPath);
92
+ if (existing && existing.fencingToken !== fencingToken) {
93
+ const err = new Error(`task contract lock held by pid=${existing.ownerPid} token=${existing.fencingToken}`);
94
+ err.code = "LEASE_HELD";
95
+ throw err;
96
+ }
97
+ throw error;
98
+ }
99
+ const confirmed = await readLock(lockPath);
100
+ if (!confirmed || confirmed.fencingToken !== fencingToken) {
101
+ const err = new Error("task contract lock acquisition race lost");
102
+ err.code = "LEASE_HELD";
103
+ throw err;
104
+ }
105
+ return {
106
+ taskId: input.taskId,
107
+ lockPath,
108
+ fencingToken,
109
+ ownerPid,
110
+ ownerStartIdentity,
111
+ record: confirmed,
112
+ release: async () => {
113
+ const current = await readLock(lockPath);
114
+ if (current && current.fencingToken === fencingToken) {
115
+ await unlink(lockPath).catch(() => { });
116
+ }
117
+ },
118
+ };
119
+ }
120
+ export async function assertLockHeld(handle) {
121
+ const current = await readLock(handle.lockPath);
122
+ if (!current || current.fencingToken !== handle.fencingToken) {
123
+ const err = new Error("task contract lock fencing token mismatch");
124
+ err.code = "LEASE_HELD";
125
+ throw err;
126
+ }
127
+ }
128
+ export async function acquireRepoMutationLease(input) {
129
+ const leaseDir = path.join(input.repoRoot, ".harness");
130
+ await mkdir(leaseDir, { recursive: true });
131
+ const lockPath = path.join(leaseDir, ".repo-mutation.lock");
132
+ const handle = await acquireTaskContractLock({
133
+ repoRoot: input.repoRoot,
134
+ taskId: "__repo__",
135
+ requestId: input.requestId,
136
+ operation: "repo-lease",
137
+ });
138
+ await writeFile(lockPath, `${JSON.stringify({
139
+ schemaVersion: 1,
140
+ fencingToken: handle.fencingToken,
141
+ ownerPid: handle.ownerPid,
142
+ ownerStartIdentity: handle.ownerStartIdentity,
143
+ acquiredAt: new Date().toISOString(),
144
+ requestId: input.requestId,
145
+ }, null, 2)}\n`, "utf-8").catch(() => { });
146
+ return {
147
+ fencingToken: handle.fencingToken,
148
+ release: async () => {
149
+ await handle.release();
150
+ await unlink(lockPath).catch(() => { });
151
+ },
152
+ };
153
+ }